doc/xls在线预览实战:LibreOffice+JODConverter转PDF全流程与避坑指南
简介面向 Java 开发者的文档转换与在线预览方案基于 Apache POI 与 iTextPDF 实现 doc、xls含新旧版本、txt、图片等文件转 PDF适用于 OA、知识库、在线办公等需要浏览器直接查看文档的场景。压缩包为 rar 格式共 6 个文件含 5 个 Java 工具类源码和 1 份 jar 版本说明 txt整体仅 7KB结构清晰、易于移植。5 个工具类按职责拆分分别对应文件转 HTML、文件访问、亚洲字体提供、Word 转 PDF、Excel 转 PDF 等完整处理链路Excel 通过 HSSF/XSSF 兼容 Excel 2003 与 2007PDF 生成结合 iText 完成文本、表格、图片排版并给出与 Spring MVC/Servlet 集成、上传后返回 PDF 链接、前端用 PDF.js 无插件预览的典型思路。版本说明文档列出了依赖 jar 的版本可帮助规避 POI 与 iText 的依赖冲突减少排错成本已有 2394 人学习下载非常适合需要快速搭建 Office 在线预览功能的 Java 开发者直接参考或二次开发。资源虽小但演示了从文件读取、内容解析、PDF 渲染到前端预览的完整闭环适合作为学习样本或项目基础。1. 文档在线预览的落地路径从doc/xls到PDF流的转换方案接了个内部需求用户上传的doc、xls要在浏览器里直接预览不能老让人下载完再打开。想过用前端解析Office文件的库试了一圈发现兼容性没法看最省事的还是后端转PDF。真正动手才发现转换引擎的选型和服务器环境的配置比写业务代码费劲——LibreOffice装好了、中文字体装好了、并发一上来又崩了。这篇文章把这条完整链路拆开从怎么选引擎、工程怎么分层到核心转换代码、常见坑的修复。适合要让系统快速具备文档预览能力的后端开发者也适合后续想扩展新格式的人。2. 技术选型与工程骨架JODConverter与三层转换架构2.1 转换引擎选型LibreOffice加JODConverter是性价比最高的组合开写之前我列过三条路线。第一条是商用SDK转换质量确实好转出来的PDF和Office里看到的几乎一模一样但授权费按年续内部系统一个月就用几千次这成本算不过来。第二条是直接用Apache POI解析docx和xlsx然后自己画PDF——这个方案我试过一阵就放弃了Word里的样式模型太复杂页眉页脚、表格嵌套、批注这些全要自己处理写完基本等于重做了一个Office渲染引擎。第三条就是开源Office套件加JODConverter库用LibreOffice的底层转换能力把PDF生成这步交给成熟的office进程Java代码只负责传文件、收结果。我最终选了第三条。LibreOffice本身支持doc、docx、xls、xlsx、ppt、pptx、txt、csv这些常见格式转PDF转换效果经过社区多年打磨整体可用度很高。JODConverter起的是桥接作用它负责启动和管理LibreOffice进程把Java侧的文件路径和转换参数传过去再拿到生成好的PDF。相比直接命令行调用sofficeJODConverter封装了进程生命周期还提供连接池能力省掉很多自己维护进程的脏活。依赖配置上我当时用的是Spring Boot项目直接引入jodconverter的starterdependency groupIdorg.jodconverter/groupId artifactIdjodconverter-local/artifactId version4.4.6/version /dependency dependency groupIdorg.jodconverter/groupId artifactIdjodconverter-spring-boot-starter/artifactId version4.4.6/version /dependency这里有个容易被坑的点JODConverter在4.x和5.x之间的API变化很大。我用的4.4.6有现成的Spring Boot starter配置起来最简单——只要在application.yml里指向LibreOffice的安装路径然后注入OfficeDocumentConverter就能用。5.x版本把starter合并进了jodconverter-local配置方式和Bean名称都变了网上很多教程还是4.x的写法直接抄会编译不过。我建议新项目直接用5.x按官方示例配老项目保持4.x别乱升。2.2 工程目录结构Controller、Service、Converter三层隔离转换逻辑和Web逻辑如果混在同一个类里后面想换引擎、加格式都会很难受。我把工程拆成了三层目录结构大概是这样的src/main/java/com/example/officepreview/ ├── controller/ │ └── FilePreviewController.java ├── service/ │ ├── PreviewService.java │ ├── FileStorageService.java │ └── impl/ │ └── PreviewServiceImpl.java ├── converter/ │ ├── DocumentConverter.java │ ├── OfficeConverter.java │ └── ConverterRegistry.java ├── config/ │ └── OpenOfficeConfig.java └── common/ ├── PreviewException.java └── Result.javacontroller层只接收上传请求、校验文件后缀、把预览URL返回给前端。service层负责整个转换流程的编排保存临时文件、调用转换器、把生成的PDF放到静态目录、清理临时文件。converter层是转换内核DocumentConverter是抽象接口OfficeConverter是LibreOffice的具体实现ConverterRegistry负责按扩展名分发给对应转换器。config层放LibreOffice的进程配置。这个分层设计的核心收益是可替换性。假设哪天LibreOffice转换效果达不到要求想换成商业SDK只需要改converter层新增一个实现类然后调整注册逻辑Controller和Service完全不用动。另一个好处是单元测试好写——测试Service时可以直接mock掉DocumentConverter接口不依赖真实Office环境。2.3 转换策略注册用注册表代替一长串if-else文件后缀会越来越多如果每加一个格式就去Controller里加一个else if代码很快会烂掉。我用了一个注册表模式每个转换器声明自己支持的扩展名系统启动时把它们收集到Map里请求进来后按后缀查找对应转换器。public interface DocumentConverter { // 返回这个转换器支持的扩展名列表如 doc, xls ListString supportExtensions(); // 把输入文件转换成PDF文件返回生成后的File File convertToPdf(File input, File outputDir) throws Exception; }Component public class ConverterRegistry { // key是扩展名小写形式value是对应的转换器实例 private final MapString, DocumentConverter converterMap new ConcurrentHashMap(); public ConverterRegistry(ListDocumentConverter converters) { for (DocumentConverter converter : converters) { for (String ext : converter.supportExtensions()) { converterMap.put(ext.toLowerCase(), converter); } } } public DocumentConverter getConverter(String ext) { DocumentConverter converter converterMap.get(ext.toLowerCase()); if (converter null) { throw new PreviewException(不支持的文件类型: ext); } return converter; } }ConverterRegistry通过Spring构造器自动注入所有DocumentConverter实现类启动时把扩展名和实例的映射关系构建好。ConcurrentHashMap保证并发读安全扩展名转小写后作为key避免上传时后缀大小写不一致导致匹配失败。之后每新增一种格式只需要写一个实现DocumentConverter的类并注册成BeanRegistry会自动收编它完全不用改分发逻辑。实际使用中supportExtensions不建议把实现细节写在代码里写死我一般会让每个转换器只声明自己能真正处理的格式。比如OfficeConverter声明doc、docx、xls、xlsx、ppt、pptx这样在Registry里查不到时能第一时间知道是格式没支持而不是在转换时黑匣子一样报错。3. 核心链路拆解一个文件从上传到预览的完整代码3.1 上传入口Controller接文件、查映射、返回预览URLController层的职责很薄。它接收MultipartFile拿到原始文件名后提取扩展名交给PreviewService去处理最后返回一个可预览的URL。我做了一个约定转换后的PDF放在/preview/{yyyyMMdd}/{uuid}.pdf这样的路径下前端拿到URL后直接拼接在/pdf/viewer?filexxx上。RestController RequestMapping(/api/preview) public class FilePreviewController { private final PreviewService previewService; public FilePreviewController(PreviewService previewService) { this.previewService previewService; } PostMapping(/upload) public ResultString upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return Result.fail(文件内容为空); } String originalName file.getOriginalFilename(); String ext StringUtils.getFilenameExtension(originalName); // 白名单校验防止上传任意文件 if (!previewService.isSupported(ext)) { return Result.fail(不支持的文件类型: ext); } // 返回可预览的URL路径 String previewUrl previewService.createPreview(file, ext); return Result.ok(previewUrl); } }getFilenameExtension是Spring提供的工具方法能正确截取最后一个点号后的后缀。白名单校验放在Controller有一个额外的好处不支持的文件类型在入口就被挡住不会进入后续的转换流程浪费系统资源。Result是一个简单的统一返回值包含code、message、data三个字段data里装着前端要用的预览路径。3.2 转换服务核心实现临时文件、LibreOffice调用、结果落盘PreviewServiceImpl负责整个编排。这层的代码是预览功能的骨干我写的思路很直接先把上传的MultipartFile写到本地临时目录再调转换器转成PDF最后把PDF挪到静态目录同时把临时文件清掉。Service public class PreviewServiceImpl implements PreviewService { private final ConverterRegistry registry; private final FileStorageService storageService; private static final String PREVIEW_ROOT /data/preview; Override public String createPreview(MultipartFile file, String ext) throws Exception { // 1. 保存上传文件到临时目录 File tempFile File.createTempFile(upload_, . ext); file.transferTo(tempFile.getAbsolutePath()); // 2. 根据扩展名找到转换器 DocumentConverter converter registry.getConverter(ext); // 3. 生成预览目录按日期分流避免文件堆积太多 String dateDir LocalDate.now().toString().replace(-, ); File previewDir new File(PREVIEW_ROOT / dateDir); if (!previewDir.exists()) { previewDir.mkdirs(); } // 4. 执行转换转换器负责生成PDF并返回文件对象 File pdfFile converter.convertToPdf(tempFile, previewDir); // 5. 清理上传的临时文件 tempFile.delete(); // 6. 返回相对路径前端拼上域名即可访问 return /preview/ dateDir / pdfFile.getName(); } }File.createTempFile生成的临时文件默认在系统/tmp目录下文件名带随机后缀不会和别的上传文件冲突。transferTo是MultipartFile的标准落盘方法但要注意它的参数是目标文件的绝对路径字符串直接传File对象在某些版本里会走另一条拷贝逻辑我习惯统一传路径少踩一些版本差异的坑。预览目录按日期分一层一方面方便排查问题另一方面给后续的清理策略留了维度。真正调用LibreOffice转换的代码在OfficeConverter里Component public class OfficeConverter implements DocumentConverter { private final OfficeDocumentConverter converter; Override public ListString supportExtensions() { return Arrays.asList(doc, docx, xls, xlsx, ppt, pptx); } Override public File convertToPdf(File input, File outputDir) throws Exception { // 输出文件名复用输入文件的base名后缀换成.pdf String baseName input.getName().replaceFirst(\\.[^.]$, ); String outputName baseName _ System.currentTimeMillis() .pdf; File outputFile new File(outputDir, outputName); // JODConverter的核心调用三步完成转换 converter.convert(input).to(outputFile).execute(); // 简单校验防止生成空文件 if (outputFile.length() 0) { throw new PreviewException(转换结果为空PDF检查源文件是否损坏); } return outputFile; } }converter.convert(input).to(outputFile).execute()是JODConverter的链式API它干的事情是把输入文件路径告诉LibreOffice发起一次转换任务等LibreOffice把PDF写到输出路径后返回。formatTo(ext)可以不传因为LibreOffice会根据输出文件的后缀自动判断目标格式。输出文件名加System.currentTimeMillis()是为了防止同名文件二次转换时互相覆盖同时也能避免浏览器端因为URL不变而缓存住旧PDF。JODConverter默认是同步执行转换期间当前线程会阻塞所以这个操作必须放到独立的线程池里执行不能在Servlet线程里裸跑——高并发下会把Tomcat的线程池打满。我会在后面的改造章节讲异步化的具体做法。3.3 PDF预览渲染浏览器内置PDF插件还是pdf.jsPDF生成好之后在线预览就简单了。浏览器对PDF的支持已经很成熟最简单的做法是直接用iframe嵌入iframe src/preview/20250512/report_123.pdf width100% height800px/iframeChrome、Edge、Firefox都会用自己的内置PDF阅读器渲染翻页、缩放、打印这些功能全有不用写任何前端代码。但这里有个隐患Safari对内嵌PDF的支持时好时坏而且内置阅读器在不同浏览器里的界面不一样做面向大众的产品时体验很难统一。所以我后来改成了pdf.js方案。pdf.js是Mozilla开源的前端PDF渲染库用Canvas把PDF画到页面上界面完全可控样式统一还能自己实现水印、禁止下载这些后续需求。接入方式也简单引入pdf.js的依赖然后// 用pdf.js加载PDF文件流并渲染到canvas async function loadPdf(pdfUrl, canvasId) { const pdf await pdfjsLib.getDocument(pdfUrl).promise; const page await pdf.getPage(1); const viewport page.getViewport({ scale: 1.5 }); const canvas document.getElementById(canvasId); canvas.width viewport.width; canvas.height viewport.height; const ctx canvas.getContext(2d); await page.render({ canvasContext: ctx, viewport: viewport }).promise; }这个函数做了三件事从URL取出PDF文档、按1.5倍缩放比例计算第一页的渲染尺寸、把页面画到指定canvas上。scale参数可以根据屏幕宽度动态计算比如canvas宽度 / 页面原始宽度这样在手机竖屏上看也能自适应。多页文档就循环调用getPage()一页一个canvas页面多的时候需要限制一次性渲染数量否则移动端会卡。用pdf.js之后还能顺手加个预览页带用户ID水印的功能从canvas上下文里画文字就行这也是我后来被业务方逼出来的需求。4. 避坑记录字体、并发与临时文件的五类翻车现场4.1 PDF中文全是方块或者乱码现象是最直观的本地Windows上转PDF一切正常部署到Linux服务器后所有中文都变成一个个小方块英文和数字正常。原因是服务器系统没有安装任何中文字体。LibreOffice在渲染PDF时依赖系统的字体库找不到中文字体就只能用默认字体替换而很多Linux精简环境的默认字体不含中文字形。解决方法是装字体# CentOS/RHEL系列 yum install -y fontconfig # 中文字体文泉驿正黑或思源黑体按需选 yum install -y wqy-zenhei-fonts # Debian/Ubuntu系列 apt install -y fonts-wqy-zenhei fonts-noto-cjk装完字体要执行fc-cache -f刷新字体缓存然后重启Java服务进程因为LibreOffice子进程是在启动时加载字体配置的不重启不生效。4.2 并发转换时LibreOffice进程直接崩溃系统上线后出现抖动同时几个用户上传文件有一部分转换请求报office process crashed严重时后续所有转换都失败。原因要分开看。JODConverter默认有一个Office进程池容量不够时会排队但LibreOffice进程本身是单实例承载任务的如果某个转换操作把进程搞挂了——比如遇到了损坏文档、超大的Excel、或者内存不足触发了OOM——进程池不会自动重启失效的进程就会持续报错。我当时的排查顺序是先看LibreOffice日志确认崩溃原因再加JVM堆内存和Office进程的MaxTasksPerProcess参数。解决的另一个关键点是给转换加锁同一时刻只让一个转换任务进入LibreOffice进程池// 串行化转换避免并发写入同一个Office进程导致状态错乱 synchronized (this) { converter.convert(input).to(outputFile).execute(); }用ReentrantLock代替synchronized能拿到超时控制防止某个坏文档把线程挂死。我后来更推荐用JODConverter自带的连接池配置把poolSize调成1配合锁一起用先把稳定性保住再谈性能。4.3 临时文件堆积导致磁盘写满系统跑了一个月运维报警说/tmp目录占了30多个G。排查后发现有两类文件没被清干净一是PreviewServiceImpl里的tempFile.delete()只删除了上传的临时文件转换过程中LibreOffice也会在/tmp下生成自己的中间文件二是转出来的PDF如果转换失败输出目录里的残留文件没有任何清理机制。解决思路是在应用里做兜底清理而不是依赖系统自动清理。我加了一个Scheduled定时任务每天凌晨把所有三天前没被访问过的预览文件删掉并单独扫描/tmp下以javautil*和osl*开头的老文件做二次清理。同时把临时文件目录从系统/tmp挪到应用自己的目录用-Djava.io.tmpdir/data/app-tmp指定这样定时任务扫起来范围可控也不怕误删系统里别的进程的文件。4.4 文件名带空格和中文导致转换后404上传的文件名如果是项目 说明 v1.2.docx转换成功后通过浏览器访问PDF的URL会404或者打开时报编码错误。问题出在URL拼接上没有做编码处理。/preview/20250512/项目 说明.pdf直接拼到HTML的src属性里浏览器会把空格当作分隔符截断URL中文在HTTP协议层也需要百分号编码。解决方法是拿到文件名后用URLEncoder.encode编码再放进URL或者干脆不在URL里暴露原始文件名——我在3.2里输出文件名时用了System.currentTimeMillis()生成随机后缀这个做法就是在一次踩坑之后定下来的彻底绕开了中文文件名问题。后端返回的URL统一是/preview/日期/uuid.pdf完全不依赖原始文件名。4.5 xls文件转换后内容严重错乱有个用户上传了一个带多个Sheet的Excel转出来的PDF第一页和Excel原表不一致数据行对不上有一部分列直接消失了。这个问题的根因往往是源文件本身不规范统计报表里有跨行合并单元格、浮动文本框、图表LibreOffice在转PDF时对这些元素的排版支持有限。我排查时先做了个对照测试用一个全新创建的简单xlsx转PDF发现完全正常就确定是源文件结构太复杂。解决方式分两层程序层面转换时给LibreOffice传只转当前活动Sheet的过滤参数如果业务方只关心第一个Sheet这样至少保证第一页是正确的流程层面给这类复杂报表的转换结果页面加一个原文件下载的兜底按钮让用户在预览效果不满意时有退路。这个兜底方案看着简单但体验提升比优化一万行转换代码都管用。5. 二次开发指南扩展新格式与异步化改造5.1 新增一种文件格式的接入步骤假设现在要支持md文件转PDF做的事情只有三件写一个实现DocumentConverter的类在supportExtensions里返回md在convertToPdf里把你的转换逻辑写上注册这个类为Spring Bean重启后ConverterRegistry自动把它收进映射Controller和Service一行都不用改。Component public class MarkdownConverter implements DocumentConverter { Override public ListString supportExtensions() { return Arrays.asList(md); } Override public File convertToPdf(File input, File outputDir) throws Exception { // 先转HTML再转PDF用常见的Markdown渲染库 String html MdToHtmlConverter.render(input); File htmlFile new File(outputDir, input.getName() .html); FileUtils.writeStringToFile(htmlFile, html, StandardCharsets.UTF_8); return HtmlToPdfConverter.convert(htmlFile, outputDir); } }核心逻辑是ConverterRegistry在构造时收集所有DocumentConverter实现新的实现类只要被Spring扫描到就自动进入分发列表。这是典型的开闭原则——对扩展开放对修改关闭。我后来一次加了Markdown和CSV两种格式只写了两个类就完事。5.2 给转换加缓存和异步化预览场景有一个规律同一个文件经常被反复查看。所以很有必要加一层结果缓存。我第一次接入缓存时直接用了一个ConcurrentHashMapkey是文件的MD5值value是PDF路径命中缓存就直接返回历史结果省掉一次完整的转换。后来文件量大了才换成Redis把预览URL的TTL设成7天。异步化的改造思路是让转换和浏览解耦用户上传后立刻返回转换中状态前端轮询查询接口转换完再跳转预览页。核心代码是把转换任务扔进独立线程池ExecutorService convertPool Executors.newFixedThreadPool(2); String submitConvertTask(MultipartFile file, String ext) { String taskId UUID.randomUUID().toString(); convertPool.execute(() - { try { createPreview(file, ext); statusMap.put(taskId, done); } catch (Exception e) { statusMap.put(taskId, error); } }); return taskId; }线程池大小按你服务器的CPU核数和内存定我在2核4G的小机器上最多开2个转换线程超过这个数LibreOffice进程的CPU占用就会导致系统整体变卡。异步化还必须配套一个失败重试机制转换报错时把异常堆栈记录下来同时给前端返回错误码否则用户只看到无限转圈。5.3 自动验证转换质量的脚本功能做完了最后一步我会写一个批量验证脚本把要支持的每种格式造一个测试样例跑一遍转换后把PDF首页转成缩略图人工翻一遍确认排版没崩。这个脚本现在成了我的固定动作——从那以后我每次调整字体、升级JODConverter版本都会强制先跑一遍批量转换把输出结果和改动前做对比确认没有引入新的回归问题。文档转换这个功能改一处环境变量都可能批量性翻车没有这层保障上线心里总是不踏实。希望帮到你。本文还有配套的精品资源点击获取