简介面向Java开发者的文档转换与在线预览工具包基于Apache POI和iTextPDF实现doc、xls、txt及图片等格式转PDF并将转换能力封装为可直接调用或接入Web项目的工具类。转换链路区分Word与Excel场景Excel处理兼容旧版xls与新版xlsx便于对接Excel 2003/2007的办公系统。压缩包共6个文件包含5个Java源文件和1个jar包版本说明txt整体仅7KB结构精简适合快速集成或二次开发。代码按功能拆分提供Word/Excel转PDF工具、文件转HTML预览工具、在线浏览逻辑以及中文字体处理模块中文字体处理可避免PDF乱码在线浏览逻辑覆盖文件读取到前端预览的衔接。txt版本说明详细列出Apache POI和iTextPDF的依赖jar包版本方便复现环境和处理版本兼容问题。已有2393人学习下载对正在调研Java文档转PDF方案或希望参考封装思路的开发者有直接帮助既可作为轻量参考实现也可在此基础上扩展接入Spring MVC或Servlet等Web项目。1. 在线预览的真相先把 doc/xls 变成 PDF再谈浏览器渲染做过办公系统的开发者都明白让用户在网页里直接看 Word 和 Excel是个比想象中麻烦得多的需求。java pdf excel 这条技术链上最绕不开的核心步骤就是把 doc、xls 这类二进制文档转换成 PDF——只有转成 PDF浏览器才能无插件预览、分页稳定、样式不塌。我见过不少团队在这个环节里反复翻车有的用 POI 硬拼页面排版有的调用在线转换接口被限流最后都老老实实回到本地转换这条路。这篇笔记是我拆过多个文档预览模块后的落地方案转换选型、服务封装、在线浏览接入、避坑记录、高级技巧照着走能少踩一半的坑。适合正在做 OA、网盘、知识库系统的后端开发者也适合想给已有系统加文档预览功能的运维和全栈工程师。2. 转换方案选型为什么最终选了 LibreOffice 无头模式2.1 四种主流方案的边界与代价实现 doc/xls 转 PDF社区里常见的路数有四种Apache POI 直接读写文档再生成 PDF、LibreOffice/OpenOffice 无头模式转换、JODConverter 封装 OpenOffice 转换、商业库如 Aspose做转换。先说结论我在模拟项目X里最终选了 LibreOffice 无头模式加 JODConverter 封装原因往下拆。Apache POI 路线是用 Java 代码直接解析 doc/xls 文件结构然后自己控制 PDF 排版。POI 对 xlsx 的单元格读取支持不错但是 doc 格式的段落、样式、页眉页脚解析非常费力因为 doc 是二进制复合文档结构不比 docx 的 XML 结构清晰。要把一个几百页的 Word 文档转成排版不乱的 PDF用 POI 得自己处理字体度量、分页逻辑、表格边框这是典型的“能做但工程量巨大”的方案。在模拟项目X里我试过用 POI 读 xls 再写 PDF光处理合并单元格和自动换行就写了三版最后一算时间成本果断放弃。适用场景仅限于对格式要求极低的纯文本内容。LibreOffice 无头模式是调用 LibreOffice 软件本身在命令行headless模式下把文档另存为 PDF。它不做格式解析而是让 LibreOffice 用自己的排版引擎重新渲染本质上是把整个办公软件变成了你的转换内核。这个方案的优点非常实在对 doc、docx、xls、xlsx、ppt、odt、rtf、txt 等几十种格式的支持是现成的转换效果和你在 LibreOffice 里打开再导出 PDF 完全一致。代价是需要服务器安装 LibreOffice 软件本体转换时是进程级操作并发高了要排队。JODConverter 封装 OpenOffice/LibreOffice是把无头模式再包一层 Java API。它维护一个常驻的 OpenOffice 进程池通过 UNO 协议把转换请求发给这个进程。避免了频繁启动 LibreOffice 的开销也能控制并发。但它对版本敏感OpenOffice 和 LibreOffice 的 UNO 接口不完全一致配置不对时容易进程假死这个坑我在避坑章节会专门讲。商业库路线如 Aspose.Words、Aspose.Cells转换质量高、不需要外部软件按调用次数或部署授权收费对预算充足的企业项目是省心方案。我用过一个评估版转换效果确实好但水印和授权校验是个麻烦事而且库的体积很大。中小型项目或自用工具优先考虑开源方案。2.2 我为什么弃用重负载方案而选无头模式最初在模拟项目X里我尝试过用 aspose 系列的转换方式效果确实漂亮但有两个现实问题一是授权费用按服务器算二是 Java 实现里 aspose 对内存的占用在批量转换时非常夸张。后来我换了思路直接让服务器上装 LibreOffice用命令行把它当转换引擎调用。这个决定的核心理由有四点第一格式覆盖广。doc、xls、ppt 老格式docx、xlsx 新格式全部由 LibreOffice 解析不需要自己写任何解析逻辑。第二转换效果稳定。LibreOffice 的排版引擎对复杂 Word 文档的处理比开源 Java 库成熟太多至少在模拟项目X里测试过的 300 多个真实文档没有出现排版全崩的情况。第三隔离性好。转换过程是独立进程就算转换某个文档导致 LibreOffice 崩溃也只是丢掉一个转换任务不会拖垮 JVM。第四部署和维护成本可控只需要在服务器上装一个软件包配置好中文字体剩下的事就交给代码调度。有得必有失无头模式最大的短板是冷启动慢。LibreOffice 进程第一次启动要加载大量组件通常需要 2 到 5 秒这个延迟对在线预览的交互体验是不小的考验。我的处理方式是保持一个常驻进程池复用启动好的 LibreOffice 实例把冷启动时间摊薄到后续每个任务上。这也正是为什么我最终使用 JODConverter 而不是直接调用 shell 命令——JODConverter 正好提供了进程管理和任务队列。2.3 环境准备LibreOffice 安装与中文字体如果你决定采用 LibreOffice 无头模式环境准备有两个关键点安装本体和补全中文字体。字体问题我在避坑章节会详细展开这里先把安装命令和验证方法说清楚。Linux 服务器上常见做法是用包管理器安装。以 CentOS 系的服务器为例你可能会执行yum install libreoffice或apt-get install libreofficeDebian 系但那样装的是完整版附带了大量用不到的组件。我一般只装核心套件# Debian/Ubuntu apt-get update apt-get install -y libreoffice-core libreoffice-common libreoffice-writer libreoffice-calc fonts-noto-cjk fonts-wqy-zenhei # CentOS/RHEL 可用 dnf 或 yum 安装对应包注意启用 EPEL 源 dnf install -y libreoffice-writer libreoffice-calc yum install -y wqy-zenhei-fonts装完后用一个简单命令验证无头模式是否可用libreoffice --headless --convert-to pdf --outdir /tmp/test /tmp/test.docx ls /tmp/test/test.pdf如果生成了 PDF 文件说明转换内核已经就绪。--convert-to pdf是转换目标格式--outdir指定输出目录这两个参数是无头模式的核心。注意--headless必须放在命令最前面否则 LibreOffice 会尝试启动 GUI 界面在无显示器的服务器上直接报错。在 ARM 架构的服务器上装 LibreOffice 偶尔会碰到依赖缺失解决办法是直接用官网提供的 deb 包手动安装不要纠结系统源里的版本。这属于环境层面的小坑先记一笔。3. 转换服务封装基于 JODConverter 的 Java 实现3.1 依赖引入与核心转换代码选定了 LibreOffice JODConverter 组合后Java 端的实现就清晰了。我用 Spring Boot 搭建转换服务先引入 jodconverter 的依赖。这里提醒一句JODConverter 4.x 和 3.x 的 API 差异很大网上的旧教程多半是 3.x 的OfficeDocumentConverter写法在 4.x 里已经废弃了。我用的是 4.2 版本官方坐标如下dependency groupIdorg.jodconverter/groupId artifactIdjodconverter-local/artifactId version4.2.0/version /dependency dependency groupIdorg.jodconverter/groupId artifactIdjodconverter-spring-boot-starter/artifactId version4.2.0/version /dependencyjodconverter-local表示本地进程模式也就是直接管理 LibreOffice 进程还有jodconverter-online模式走 LibreOffice Online 的 API但部署更复杂本地模式就够了。引入依赖后配置类的核心参数如下jodconverter: local: enabled: true office-home: /usr/lib/libreoffice # LibreOffice 安装目录 port-numbers: 2001,2002,2003 # 每个端口对应一个进程实例 task-queue-timeout: 60000 # 任务队列超时毫秒数 task-execution-timeout: 120000 # 单个任务执行超时毫秒数 max-tasks-per-process: 200 # 每个进程最多执行任务数防止内存膨胀office-home指向 LibreOffice 安装目录这个路径写错会导致启动失败。port-numbers配置多个端口可以拉起多个进程实例做并行转换我一般开 2 到 3 个端口。max-tasks-per-process很关键LibreOffice 进程长时间运行会内存增长跑到一定任务数后自动重启进程是常见的健康策略。然后写转换服务类Component public class DocumentConverterService { private final OfficeManager officeManager; public DocumentConverterService(OfficeManager officeManager) { this.officeManager officeManager; } public File convertToPdf(File sourceFile, String extension) throws OfficeException { // 转换后的 PDF 文件名与源文件同名输出到临时目录 File targetFile new File(sourceFile.getParentFile(), UUID.randomUUID() .pdf); // 根据源文件扩展名动态选择加载属性 OfficeDocument source OfficeDocumentBuilder.builder() .document(sourceFile) .properties(propertyForExtension(extension)) .build(); // 执行转换输出格式固定为 PDF officeManager.convert(source) .as(OfficeDocument.PDF) .to(targetFile) .execute(); return targetFile; } private MapString, Object propertyForExtension(String ext) { MapString, Object props new HashMap(); if (txt.equalsIgnoreCase(ext)) { // 纯文本文件需要指定编码和语言否则中文会乱码 props.put(FilterOptions, UTF8); } return props; } }OfficeDocumentBuilder是 4.x 的构建器 API用来描述源文档信息。如果对扩展名判断不严格也可以直接new OfficeDocument(sourceFile)但那样 txt 文件极容易乱码。我这里针对 txt 场景设置了FilterOptions参数这个参数就是 CommonFilter 的选项不同格式支持的选项不同需要查阅 LibreOffice 文档确认。转换执行的逻辑理解起来很直接构建源文档对象声明目标格式为 PDF指定输出文件然后交给officeManager执行。officeManager背后是把请求投递给一个 LibreOffice 进程进程完成转换后返回。整个调用是同步的转换失败会抛出OfficeException捕获后做降级处理即可。3.2 文件类型识别与文件名兼容处理转换服务最容易被忽略的是文件类型识别。很多上传组件拿到的文件名是不可信的尤其是 Windows 用户上传的文件可能叫“报表(最终版).xls”或者扩展名是大写。直接按扩展名判断会出问题。我用的是 Apache Tika 做 mime type 检测再映射到 JODConverter 支持的格式public String detectExtension(byte[] fileBytes, String originalName) { try { // Tika 读取文件的真实类型不依赖文件名 Tika tika new Tika(); String mimeType tika.detect(fileBytes, originalName); // MIME 类型映射成 LibreOffice 能识别的扩展名 MapString, String mimeToExt new HashMap(); mimeToExt.put(application/msword, doc); mimeToExt.put(application/vnd.openxmlformats-officedocument.wordprocessingml.document, docx); mimeToExt.put(application/vnd.ms-excel, xls); mimeToExt.put(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, xlsx); String ext mimeToExt.get(mimeType); return ext ! null ? ext : docx; // 无法识别时给默认值 } catch (Exception e) { return docx; } }用 Tika 的好处是可靠它读文件头而不是看文件名能识别出大量伪装成 doc 实为 xls 的情况。Tika 的坐标是org.apache.tika:tika-core在 pom 里加上即可。文件名兼容处理则是为了防止 PDF 输出路径出错。源文件上传后我统一在服务端重新生成文件名存成 UUID 加原始扩展名所有后续处理都用这个新文件名。这样既避免中文路径问题也避免文件名中的特殊字符比如%、#、?干扰 LibreOffice 的文件解析。public File saveUploadFile(MultipartFile uploadFile) throws IOException { String originalName uploadFile.getOriginalFilename(); String ext detectExtension(uploadFile.getBytes(), originalName); // 用 UUID 重命名原始文件名只作为业务字段保存 String newName UUID.randomUUID().toString().replace(-, ) . ext; File savedFile new File(uploadDir, newName); uploadFile.transferTo(savedFile); return savedFile; }savedFile.getParentFile().mkdirs()要记得在 uploadDir 不存在时先创建目录这是新手容易漏的一步。转换完成后的 PDF 文件我同样用 UUID 命名与源文件做一一映射关系存数据库这样预览时不用暴露真实文件名也算一点安全考虑。3.3 转换任务与预览的无缝衔接转换是耗时操作用户上传后如果界面一直等待同步转换结果体验会很差。我的做法是把转换拆成异步任务上传完成后立即返回一个任务 ID前端轮询转换状态转换完成后展示 PDF 预览链接。这个异步方案里最核心的是一个任务状态表的设计。我建了一个CONVERT_TASK表字段包括task_id、source_file_name、source_path、pdf_path、status、error_msg、create_time。status字段取值四种WAITING、RUNNING、SUCCESS、FAILED。异步执行时的代码逻辑如下Async(convertExecutor) public void convertTask(String taskId) { ConvertTask task taskMapper.selectById(taskId); task.setStatus(RUNNING); taskMapper.updateById(task); try { File sourceFile new File(task.getSourcePath()); File pdfFile documentConverterService.convertToPdf( sourceFile, detectExtension(readFileHead(sourceFile), sourceFile.getName())); // 转换成功后更新任务状态和PDF路径 task.setStatus(SUCCESS); task.setPdfPath(pdfFile.getAbsolutePath()); } catch (OfficeException e) { task.setStatus(FAILED); task.setErrorMsg(e.getMessage()); log.error(转换失败taskId{}, taskId, e); } finally { taskMapper.updateById(task); } }用Async注解把任务丢进线程池注意线程池的队列要设置CallerRunsPolicy拒绝策略否则并发高峰期会导致任务直接丢弃。我在配置里把核心线程设为 CPU 核数减一最大线程数等于可用的 LibreOffice 进程数避免线程过多全堵在 Office 进程队列上。前端预览的接口返回 JSON任务状态是SUCCESS时附带一个 PDF 访问路径FAILED时附带错误信息。预览页拿到路径后交给 PDF 渲染组件加载这样就完成了一条完整的“上传 → 转换 → 在线预览”链。4. 在线浏览接入PDF.js 与浏览器原生预览的选择4.1 PDF.js 分页加载与性能边界转换完成只是第一步真正让用户像看图一样翻文档的是在线浏览部分。浏览器原生支持 PDF 预览直接扔一个iframe指向 PDF 文件地址就能看但原生预览有两个问题一是 UI 是浏览器自带的与业务系统风格不一致二是无法统计用户阅读进度也没法在页面上叠加水印或操作按钮。所以多数业务系统里我见过最多的做法是用 PDF.js 自定义渲染。PDF.js 是 Mozilla 开源的 PDF 渲染引擎纯 JavaScript 实现不需要浏览器插件。引入它的常见做法是把源码拷贝到项目静态目录然后通过其pdf.js和pdf.worker.js两个文件加载。简化版的使用如下// 引入 pdf.js 后通过异步加载 PDF 并渲染第一页 const loadingTask pdfjsLib.getDocument({ url: /preview/pdf/ taskId, cMapUrl: /lib/cmaps/, cMapPacked: true }); loadingTask.promise.then(pdf { // 渲染第一页到 canvas pdf.getPage(1).then(page { const viewport page.getViewport({ scale: 1.5 }); const canvas document.getElementById(pdf-canvas); canvas.width viewport.width; canvas.height viewport.height; const ctx canvas.getContext(2d); const renderTask page.render({ canvasContext: ctx, viewport: viewport }); return renderTask.promise; }); });scale参数控制渲染清晰度1.5 是屏幕预览的常用值cMapPath指向 PDF.js 自带的字符映射文件处理中文 PDF 时必不可少。这段代码会渲染第一页翻页时类似地getPage(n)再渲染形成一个完整的分页阅读器。PDF.js 的性能边界我要强调一句它在内存里的瓶颈不是文件大小而是单页渲染的复杂度。一个 200MB 的 PDF 如果第一页是高清扫描图片渲染照样卡顿。此时唯一的出路是服务端对超大 PDF 做分片优化或者在转换阶段就把目标文档拆成多个小 PDF这个思路我在最后一章会展开。4.2 预览接口鉴权与防直接下载在线预览最容易犯的安全错误是直接把静态 PDF 放到公网路径下用户拿到 URL 就能下载原文件甚至绕过业务系统的权限控制直接访问未授权的文档。这里我采用了两层策略一是所有 PDF 文件放在 Web 应用不可直接访问的目录下二是通过一个带鉴权的接口输出 PDF 字节流。GetMapping(/preview/pdf/{taskId}) public ResponseEntityInputStreamResource previewPdf( PathVariable String taskId, HttpServletResponse response) throws IOException { // 鉴权校验当前登录用户是否有权访问该任务省略具体实现 if (!permissionService.checkAccess(taskId)) { response.setStatus(HttpStatus.FORBIDDEN.value()); return null; } ConvertTask task taskMapper.selectById(taskId); File pdfFile new File(task.getPdfPath()); if (!pdfFile.exists()) { response.setStatus(HttpStatus.NOT_FOUND.value()); return null; } // 以流的形式输出PDF前端不感知真实路径 InputStreamResource resource new InputStreamResource(new FileInputStream(pdfFile)); return ResponseEntity.ok() .header(Content-Type, application/pdf) .header(Content-Disposition, inline; filename\ task.getSourceFileName() .pdf\) .body(resource); }按Content-Disposition: inline输出表示浏览器内嵌显示而不是附件下载application/pdf类型则保证预览组件能正确识别。这里有个常见误区如果用了 PDF.js 的getDocument({ url: ... })请求头带了认证信息的话没问题但如果选择 iframe 方式预览iframe 的 src 不能带自定义 Header访问题就得改用带 token 参数的 URL。我一般用短时效 token 拼在 URL 上避免 token 泄露导致越权。4.3 大文件预览的降级策略在线预览不能让用户等太久更不能因为一张 PDF 把浏览器搞崩溃。我制定的降级策略是三层文件大小小于 10MB 时走 PDF.js 完整预览10 到 50MB 的走 PDF.js 分页懒加载只渲染当前可视区域附近的页面超过 50MB 的直接提示用户下载后查看。// 大文件分页懒加载的核心是监听滚动或翻页事件 // 每次只渲染当前页和前后一页翻页时释放旧页面 let currentPageNum 1; async function renderPage(pdfDoc, pageNum) { const page await pdfDoc.getPage(pageNum); const viewport page.getViewport({ scale: 1.25 }); // 只渲染需要的页面渲染前取消上一次未完成的渲染任务 if (renderTask) { renderTask.cancel(); } const canvas pageCanvas; canvas.width viewport.width; canvas.height viewport.height; renderTask page.render({ canvasContext: canvas.getContext(2d), viewport: viewport }); await renderTask.promise; }上述代码在翻页时会做增量渲染用户快速翻页时不会积压渲染任务。参数scale动态适配高清屏用 1.5普通屏用 1.25这是视觉效果和性能之间最常见的平衡点。这套方案在模拟项目X里扛住了日常办公场景几十页的 Word 导出 PDF 在普通办公电脑上流畅翻页没有明显的白屏等待。5. 避坑指南文档转换与在线预览的五个血泪教训5.1 转换出来的 PDF 中文全是方框现象docx 转 PDF 后中文变成一个个方框无法阅读。英文和数字正常。原因服务器上的 LibreOffice 没有安装中文字体。开发者的本机一般都装了微软雅黑等字体上到纯 Linux 服务器后字体缺失LibreOffice 回退到默认字体而默认字体不含中文字形。解决安装中文字体包刷新字体缓存。Debian 系统执行apt-get install -y fonts-noto-cjk fonts-wqy-zenhei fc-cache -fv fc-list :langzh-cnfc-list :langzh-cn会列出系统已有的中文字体如果输出为空说明字体没装上。我踩过这个坑之后每次部署服务器都强制先执行这组命令属于环境设置里的固定动作。5.2 LibreOffice 进程跑几天后假死转换任务全挂现象服务运行三四天后所有转换任务超时查看进程发现 LibreOffice 还在但任务就是无法完成。重启服务后恢复。原因LibreOffice 长驻进程会有内存泄漏或状态异常尤其是在加载过大量不同格式的文件后。JODConverter 虽然维护了进程池但默认情况下进程是不定期重启的跑久了进程就处在一个坏状态里。解决设置max-tasks-per-process让 JODConverter 在进程处理完一定任务数后自动杀掉并重启进程。我在配置里设置max-tasks-per-process: 200同时把 office-home 指向一个稳定版本目录。之后每隔几百个任务进程自动重启一次再也没有出现过假死情况。另外把task-execution-timeout设置为 120000 毫秒超过 2 分钟的任务强制失败避免一个坏文档卡住整个队列。5.3 Excel 转 PDF 后分页把表格从中间切开现象一个内容很多的工作表转 PDF 后一页纸的两端恰好把某几行数据劈成两半阅读体验很差。原因LibreOffice 转换 xls 到 PDF 时默认按纸张大小自动分页。如果用户原始文件没有设置分页符也没有调整打印区域转换器就按默认策略硬切页。解决在转换前对表格对象的打印设置做调整。使用 JODConverter 时传递FilterData参数指定适合屏幕阅读的分页设置// 构造 Excel 转 PDF 的筛选参数 MapString, Object filterData new HashMap(); // 使用 A3 横向纸张减少分页次数 filterData.put(PaperSize, A3); filterData.put(Orientation, LANDSCAPE); // 自动检测表格实际内容区域避免无效空白页 filterData.put(AutoDetect, true); props.put(FilterData, filterData);Orientation取LANDSCAPE时表格横向排版列数多的 Excel 转出来的 PDF 更易读。AutoDetect会让转换器自动框选有内容的区域而不是整张工作表。这两个参数配合使用后模拟项目X里的报表类文档再也没有出现过把表格切断的投诉。5.4 xls 老格式在 Linux 上转换报错或输出空 PDF现象新版的 xlsx 转 PDF 正常旧版 xls 文件转换时偶尔报 “General Error” 或生成 0 字节 PDF。原因xls 是二进制格式LibreOffice 的 Calc 对它的兼容是通过导入过滤器实现的。某些 xls 文件由老版本的 WPS 或其他第三方软件生成文件结构不规范导入时计算引擎出错。解决先确认连接的是 LibreOffice 的 Calc 模块而不是误装了精简版导致组件缺失。另一个有效的土办法是把 xls 先转成 xlsx 再做二次转换——用无头模式先转一次格式再转 PDF容错率高很多libreoffice --headless --convert-to xlsx --outdir /tmp/intermediate /tmp/source.xls libreoffice --headless --convert-to pdf --outdir /tmp/out /tmp/intermediate/source.xlsx代码层面对应OfficeDocumentBuilder先加载 xls调用.as(OfficeDocument.XLSX)然后execute()拿到新的 xlsx 文件后继续转换 PDF。这种双重转换会多花一两秒但换来的是稳定。如果某个 xls 文件连 xlsx 都转不了一般就建议用户上传新版格式了。5.5 高并发下转换队列堆积用户迟迟看不到预览现象多人同时上传文档时任务队列排到几十个后面的任务要等几分钟才能看到预览。原因LibreOffice 进程数是有限的。我最初只配了一个进程一个文档转换耗时 2 到 4 秒队列一长延时立刻上去。加上 JODConverter 的默认队列是先进先出没有做优先级区分。解决多管齐下。首先把port-numbers配置成多个端口例如 2001、2002、2003相当于拉起三个 LibreOffice 实例并行处理。其次对转换任务的线程池设置合理的队列容量和拒绝策略。最后做优先级标记短小文件优先级高超大文件降级页面预览需要的任务插队处理。任务优先级的实现不复杂给ConvertTask加一个priority字段读取队列时先按优先级排序再取出。实际效果是普通用户的小文件基本无感只有大文件被延后处理模拟项目X里 50 并发的情况下预览响应时间稳定在三到五秒内。6. 进阶技巧批量转换、缓存复用与字体热加载先聊批量转换的工程化处理。多数系统里文档转换不是单个发生的而是会有“整个目录打包转换”或“历史数据清洗”的需求。批量转换与单文件转换的区别在于任务失败不能中断整体流程以及资源复用更讲究。我的做法是把批量任务拆成一个一个的小任务失败的单条记录错误后继续执行后续任务public ListConvertResult batchConvert(ListFile sourceFiles) { ListConvertResult results new ArrayList(); for (File file : sourceFiles) { try { File pdf convertToPdf(file, detectExtension(readFileHead(file), file.getName())); results.add(new ConvertResult(file.getName(), true, pdf.getAbsolutePath())); } catch (OfficeException e) { // 记录失败原因不影响其他文件转换 results.add(new ConvertResult(file.getName(), false, e.getMessage())); log.warn(批量转换失败{}, file.getName(), e); } } return results; }批量转换最容易踩的坑是同时把几十个文件打进同一个 LibreOffice 进程内存直接拉满。我在批量场景中会额外控制一个信号量保证同时只有两个转换任务在跑剩下的排队。这样 CPU 和内存都有缓冲转换完成率反而比一口气全塞进去高。缓存复用是另一个值得投入的点。相同文件的多次预览转换是浪费资源的比如十个用户都看同一份通知文件。我的处理是在上传后计算文件的 SHA-256 值数据库里存一个source_md5字段转换前先查一下有没有同样 md5 的文件已经转换成功过。如果有直接复用旧的 PDF 路径不再触发转换public File getOrConvert(byte[] fileBytes, String originalName) throws IOException { String md5 DigestUtils.sha256Hex(fileBytes); ConvertTask cached taskMapper.selectBySourceMd5(md5); if (cached ! null SUCCESS.equals(cached.getStatus())) { return new File(cached.getPdfPath()); } // 没有缓存时走新建转换任务逻辑 File sourceFile saveUploadFile(fileBytes, originalName); return convertAndRecord(sourceFile, md5); }这套去重逻辑对周报、通知、制度文件这类高频预览场景非常有效模拟项目X上线后预览接口的转换量直接下降了四成。但要设置一个缓存失效时间业务上规定文件更新后旧的 PDF 缓存不再返回我一般用 24 小时的过期策略。最后说字体热加载这个高阶技巧。前面提到中文字体缺失会导致转换乱码但有一种更隐蔽的情况服务器装了字体但 LibreOffice 进程启动后又在系统里新增了字体不重启进程的话新字体不会被加载。解决办法是在代码中周期扫描字体目录变化如果有变化就把 OfficeManager 停掉再重启或者在部署阶段一次性把字体全部准备到位避免运行期改动。我个人的习惯是转换服务上线前用脚本把所有字体的 TTF 文件全部拷贝到/usr/share/fonts/然后执行fc-cache -fv之后写一个测试用例专门验证中文文档转换输出是否能正常提取文字层。从那以后我每次处理文档转换服务都强制先跑一遍这个字体检查用例确认fc-list :langzh-cn有输出再开始接业务再也没有遇到过用户反馈“预览乱码”的翻车情况。希望帮到你。本文还有配套的精品资源点击获取