☰
Java文档在线预览实战:LibreOffice与PDFBox实现格式转换
2026/10/26 14:03:26 网站建设 项目流程

简介:面向Java后端开发者与文档处理场景的实用代码资源,聚焦doc、xls、txt及图片等多格式文件转PDF并支持在线浏览的完整实现思路。内容围绕Apache POI的HSSF与XSSF两套API展开,分别处理Excel 97-2003与2007+格式的读写,并结合iTextPDF完成PDF文档创建、段落表格与图片插入、字体样式设置;同时涉及Web应用集成、转换后文件存储与URL访问、PDF.js无插件预览以及上传下载安全与缓存性能优化等要点。压缩包共6个文件,以5个java源码为主,涵盖Word转PDF、Excel转PDF、文件转HTML及中文字体支持等工具类,另附1个txt版本说明,整体约7KB,轻量便于直接参考复用。已有2394人学习下载,适合需要快速搭建格式转换与在线预览功能的中级开发者对照实践。

1. 文档在线预览的最后一公里:为什么我选 Java 做格式转换

后台管理系统里塞满合同、报表、发票,用户点一下就想在浏览器里直接看,结果 PDF 能开、Word 和 Excel 一打开就是下载框,或者干脆乱码。这个场景几乎每个做企业内网、OA、档案系统的团队都会撞上。我这次拆的是一个用 Java 把 doc、docx、xls、xlsx、ppt、pptx 统一转成 PDF、再交给前端在线浏览的项目,核心思路很朴素:服务端先把异构文档收敛成 PDF 这一种格式,浏览器只负责渲染 PDF,兼容性问题一次性解决。它适合正在做文档中心、合同管理、报表预览的后端开发者,也适合想搞清楚 Java 文档转换链路到底怎么落地的人。下面从技术选型一路讲到怎么跑、怎么改。

2. 技术栈与目录结构:转换链路是怎么搭起来的

2.1 整体架构:上传、转换、存储、预览四段式

这个项目的骨架是典型的 Spring Boot 单体应用,转换能力单独抽成一个服务层,不跟业务 Controller 搅在一起。整条链路分四段:前端上传原始文档,Controller 接收后落到临时目录,转换服务根据扩展名分派到不同的转换器,产出 PDF 后写入文件存储目录,最后返回一个可访问的预览地址给前端。前端拿到地址后用浏览器内置的 PDF 阅读器或者 pdf.js 打开,不再关心原始格式。

分层上大致是这样:

  • controller层:接收上传、触发转换、返回预览 URL,不写转换逻辑。
  • service层:转换调度中心,按文件类型选择转换器,统一异常处理。
  • converter层:每种格式一个实现类,doc/docx 走一套,xls/xlsx 走一套,ppt/pptx 走一套。
  • storage层:本地磁盘或对象存储的抽象,负责临时文件和成品 PDF 的落盘。
  • config层:转换工具的参数配置,比如字体路径、临时目录、超时时间。

这种拆法的好处是转换器可插拔。以后要加 OFD、RTF,只要实现同一个接口注册进去,调度层不用动。常见做法是定义一个DocumentConverter接口,带supports(String ext)和convert(File src, File dest)两个方法,Spring 启动时把所有实现类注入成 List,调度时遍历匹配。

2.2 技术选型:为什么是 LibreOffice 加 PDFBox 这套组合

Java 生态里做文档转 PDF,绕不开几条路。纯 Java 库像 Apache POI 能读 docx/xlsx,但它不负责排版渲染,你拿到的是一堆段落和单元格对象,要自己画成 PDF,工作量巨大且排版还原度差。商业方案稳定但要授权。这个项目走的是「调用本地转换引擎 + Java 做调度和兜底」的路线,主流选择是 LibreOffice 的无头模式(headless),配合 PDFBox 做后续处理。

具体分工是这样的:

组件职责说明
LibreOffice headlessdoc/docx/xls/xlsx/ppt/pptx 转 PDF命令行调用,排版还原度高
Apache POI读取文档元信息、做格式校验不参与渲染
PDFBoxPDF 合并、加水印、页数统计、文本抽取转换后的二次加工
Spring BootWeb 层与调度提供上传和预览接口

选 LibreOffice 的核心理由是它一套引擎覆盖 Office 全家桶,不用为每种格式找不同库,维护成本低。代价是它是个外部进程,调用有开销、有并发瓶颈,这些后面在避坑章节会细说。PDFBox 则是纯 Java,用来做转换之后的收尾工作,比如给预览 PDF 打上「内部资料」水印、统计页数用于前端分页。

2.3 目录结构:源码怎么摆

项目目录大致长这样,读源码时按这个顺序找入口最省时间:

document-preview/ ├── pom.xml ├── src/main/java/com/example/preview/ │ ├── PreviewApplication.java # 启动类 │ ├── controller/ │ │ ├── DocumentController.java # 上传与预览接口 │ │ └── GlobalExceptionHandler.java # 统一异常 │ ├── service/ │ │ ├── ConvertService.java # 转换调度 │ │ └── impl/ConvertServiceImpl.java │ ├── converter/ │ │ ├── DocumentConverter.java # 转换器接口 │ │ ├── OfficeConverter.java # LibreOffice 调用 │ │ └── PdfPostProcessor.java # PDFBox 后处理 │ ├── storage/ │ │ └── LocalFileStorage.java # 本地存储实现 │ └── config/ │ └── ConvertProperties.java # 转换参数配置 └── src/main/resources/ ├── application.yml └── static/viewer.html # 简易预览页

converter包是整个项目的重心,service只是编排。读代码建议从DocumentController的接口定义入手,顺着ConvertService看调度,再钻进OfficeConverter看命令行怎么拼。config里的ConvertProperties决定了字体目录、临时目录、超时这些关键参数,跑不起来十有八九是这里没配对。

3. 核心转换模块拆解:从一次上传到可预览 PDF

3.1 上传接口与文件校验

Controller 层只做三件事:接文件、做基础校验、交给 service。校验包括扩展名白名单、文件大小上限、空文件判断。扩展名白名单很关键,不能让用户传个.exe进来触发转换引擎。

@RestController @RequestMapping("/api/doc") public class DocumentController { private final ConvertService convertService; public DocumentController(ConvertService convertService) { this.convertService = convertService; } @PostMapping("/convert") public ResponseEntity<Map<String, Object>> convert(@RequestParam("file") MultipartFile file) { // 1. 空文件直接拒绝 if (file.isEmpty()) { return ResponseEntity.badRequest().body(Map.of("msg", "文件为空")); } // 2. 取扩展名并转小写,做白名单校验 String original = file.getOriginalFilename(); String ext = original == null ? "" : original.substring(original.lastIndexOf('.') + 1).toLowerCase(); if (!Set.of("doc", "docx", "xls", "xlsx", "ppt", "pptx").contains(ext)) { return ResponseEntity.badRequest().body(Map.of("msg", "不支持的格式: " + ext)); } // 3. 交给转换服务,返回预览地址 String previewUrl = convertService.convertToPdf(file, ext); return ResponseEntity.ok(Map.of("url", previewUrl)); } }

这段代码里ext的提取方式要注意,lastIndexOf('.')找不到点会返回 -1,加 1 后是 0,substring(0)拿到整个文件名,白名单校验会把它挡掉,所以不会出错但语义上不够干净,实际项目里我会先判断contains(".")。convertService.convertToPdf返回的是相对路径或带签名的访问地址,前端直接塞进 iframe 或 pdf.js 的file参数即可。

3.2 转换调度:按扩展名分派到对应转换器

调度层的职责是把「文件类型」翻译成「用哪个转换器」。这里用策略模式,所有转换器实现统一接口,Spring 注入成 List,运行时按supports匹配。

public interface DocumentConverter { boolean supports(String ext); void convert(Path source, Path target) throws ConvertException; } @Service public class ConvertServiceImpl implements ConvertService { private final List<DocumentConverter> converters; private final LocalFileStorage storage; public ConvertServiceImpl(List<DocumentConverter> converters, LocalFileStorage storage) { this.converters = converters; this.storage = storage; } @Override public String convertToPdf(MultipartFile file, String ext) { Path source = storage.saveTemp(file); // 落临时文件 Path target = storage.newPdfPath(); // 预生成目标路径 DocumentConverter converter = converters.stream() .filter(c -> c.supports(ext)) .findFirst() .orElseThrow(() -> new ConvertException("无匹配转换器: " + ext)); converter.convert(source, target); // 执行转换 return storage.toPreviewUrl(target); // 转成可访问地址 } }

converters是构造器注入的 List,Spring 会把所有DocumentConverter实现按顺序塞进来,supports谁先返回 true 就用谁。这种写法扩展性好,但要注意实现类的顺序,如果两个转换器都声称支持doc,先注册的赢。实际项目里我会在supports里写精确匹配而不是前缀匹配,避免doc误吞docx。storage.saveTemp和newPdfPath把文件系统细节挡在 service 之外,以后换对象存储只改 storage 实现。

3.3 LibreOffice 命令行调用:参数怎么拼、超时怎么控

真正干活的是OfficeConverter,它把源文件路径和目标目录拼成一条soffice命令,用ProcessBuilder拉起子进程,等它退出后检查目标 PDF 是否生成。

@Component public class OfficeConverter implements DocumentConverter { private final ConvertProperties props; public OfficeConverter(ConvertProperties props) { this.props = props; } @Override public boolean supports(String ext) { return Set.of("doc", "docx", "xls", "xlsx", "ppt", "pptx").contains(ext); } @Override public void convert(Path source, Path target) throws ConvertException { // soffice 命令:无头模式、指定输出目录、指定输入文件 List<String> cmd = List.of( props.getSofficePath(), // 例如 /usr/bin/soffice "--headless", "--norestore", "--convert-to", "pdf", "--outdir", target.getParent().toString(), source.toAbsolutePath().toString() ); try { Process process = new ProcessBuilder(cmd) .redirectErrorStream(true) .start(); // 超时控制,防止大文件卡死线程 boolean finished = process.waitFor(props.getTimeoutSeconds(), TimeUnit.SECONDS); if (!finished) { process.destroyForcibly(); throw new ConvertException("转换超时: " + source); } if (process.exitValue() != 0) { throw new ConvertException("转换失败, exit=" + process.exitValue()); } } catch (IOException | InterruptedException e) { Thread.currentThread().interrupt(); throw new ConvertException("调用转换引擎异常", e); } } }

几个参数值得说清楚。--headless让 LibreOffice 不弹界面,服务器上必须加。--norestore避免它尝试恢复上次会话,减少启动开销。--convert-to pdf指定目标格式,--outdir是输出目录,注意 LibreOffice 生成的文件名默认跟源文件同名只是扩展名变 pdf,所以target的路径要和这个规则对齐,否则会找不到产物。timeoutSeconds是血泪经验,一个几十兆的 Excel 带复杂图表,转换几分钟不稀奇,不设超时线程池很快被占满。redirectErrorStream(true)把 stderr 并到 stdout,方便出错时打日志排查。

3.4 PDFBox 后处理:水印、页数与文本抽取

转换出来的 PDF 往往还要加工。PDFBox 在这里承担三件事:给每页加水印、统计页数返回给前端做分页、抽取文本用于全文检索。

@Component public class PdfPostProcessor { public int addWatermarkAndCount(Path pdf, String watermark) throws IOException { try (PDDocument doc = PDDocument.load(pdf.toFile())) { int pages = doc.getNumberOfPages(); for (PDPage page : doc.getPages()) { PDPageContentStream cs = new PDPageContentStream( doc, page, PDPageContentStream.AppendMode.APPEND, true, true); cs.beginText(); cs.setFont(PDType1Font.HELVETICA_BOLD, 40); cs.setNonStrokingColor(200, 200, 200); // 浅灰,不挡正文 cs.newLineAtOffset(150, 300); // 坐标按页面尺寸调 cs.showText(watermark); cs.endText(); cs.close(); } doc.save(pdf.toFile()); // 原地覆盖 return pages; } } }

AppendMode.APPEND表示在原有内容后追加,不会清空正文。setNonStrokingColor用浅灰是为了水印不干扰阅读,深色水印在合同预览里很碍眼。newLineAtOffset的坐标是相对页面左下角的,A4 页面宽约 595、高约 842,写死 150/300 只适合固定纸张,实际项目里我会根据page.getMediaBox()的宽高算居中位置。doc.save原地覆盖有风险,转换失败会毁掉原文件,稳妥做法是先存临时文件再原子替换。

4. 跑起来:环境准备与启动验证

4.1 环境依赖:JDK、LibreOffice 与字体

跑这个项目,机器上必须装三样东西:JDK 17(Spring Boot 3 的基线)、Maven、LibreOffice。LibreOffice 不是 Java 依赖,是系统级软件,pom.xml里引不进来,得单独装。Linux 上常见做法是包管理器安装,装完确认soffice命令在 PATH 里。

# 确认 JDK 与 Maven java -version mvn -v # 确认 LibreOffice 可执行文件位置 which soffice soffice --version

which soffice的输出要填到application.yml的soffice-path里。如果输出为空,说明没装或者不在 PATH,转换一定失败。字体是另一个隐形依赖,服务器上缺中文字体,转换出来的 PDF 中文全是方块,这个坑后面单独讲。

4.2 配置项:application.yml 里几个必须改的值

convert: soffice-path: /usr/bin/soffice timeout-seconds: 120 temp-dir: /data/preview/temp pdf-dir: /data/preview/pdf watermark: 内部资料

soffice-path按which结果填。timeout-seconds根据业务文件大小调,报表类给 120 秒起步,纯文字合同 60 秒够。temp-dir和pdf-dir要确保运行用户有写权限,容器里跑经常因为目录属主不对报权限错误。watermark留空则跳过水印逻辑。这几个值对应ConvertProperties里的字段,改完重启生效。

4.3 启动与接口验证

mvn clean package -DskipTests java -jar target/document-preview-0.0.1.jar

启动后先用 curl 打一次转换接口,确认链路通:

curl -X POST http://localhost:8080/api/doc/convert \ -F "file=@/tmp/test.docx"

返回 JSON 里带url字段就算成功,把这个 url 贴到浏览器能打开 PDF 即闭环。如果返回 500,先看日志里exit=的值,非 0 基本是 LibreOffice 调用问题;如果返回 400,是白名单或空文件校验挡的。验证顺序建议是:先传一个纯文字 docx,再传带表格的 xlsx,最后传带图表的 pptx,逐步加压,出问题好定位是哪类格式的坑。

5. 避坑与排查:转换链路上最容易翻车的几处

5.1 中文变方块:字体缺失

现象:转换成功,PDF 能打开,但中文全是方框或乱码。原因:LibreOffice 渲染时找不到对应中文字体,回退到不含中文字形的默认字体。解决:在服务器上安装常用中文字体(如思源黑体、文泉驿),装完执行fc-cache -fv刷新字体缓存,再重启应用。容器化部署时把字体目录挂载进去,别指望基础镜像自带。

5.2 并发一高就卡死:LibreOffice 单实例锁

现象:单次转换正常,压测或多用户同时上传时大量超时。原因:LibreOffice 同一用户配置目录下默认单实例,多个进程抢同一个 profile 会互相阻塞。解决:给每次调用指定独立的用户配置目录,命令行加-env:UserInstallation=file:///tmp/lo_<uuid>,让每个进程用独立 profile。代价是启动开销变大,所以通常配合进程池或队列限流,而不是无限并发。

5.3 转换成功但找不到 PDF:输出文件名对不上

现象:exit=0但目标路径没有文件。原因:LibreOffice 输出文件名默认取源文件名改扩展名,如果代码里target用了随机 UUID 命名,产物名字和预期不一致。解决:要么让target跟源文件同名,转换后再重命名;要么转换后扫描outdir找最新生成的 pdf 再搬走。我一般选后者,避免同名覆盖。

5.4 大文件把线程池打满:缺超时和限流

现象:偶发接口整体无响应。原因:个别超大 Excel 转换耗时极长,占住工作线程,后续请求排队。解决:waitFor必须带超时并destroyForcibly,同时在转换入口加信号量或队列限制并发数,超出直接返回「排队中」。转换是重 CPU 操作,线程池开太大反而互相拖慢。

5.5 临时文件堆积:异常路径没清理

现象:磁盘慢慢被占满。原因:转换失败或超时后,临时源文件和半成品 PDF 没删。解决:用try-finally包住转换流程,finally 里删临时源文件;成品 PDF 按业务保留期定时清理。别把清理逻辑散在各处,统一放 storage 层做。

6. 二次开发:把转换服务改造成可扩展的预览中台

拿到这个项目后,多数人不会止步于「能转就行」,而是想把它变成团队共用的预览能力。我踩过的改造路径大致分三步,按投入产出比排序。

第一步是把同步转换改成异步任务。上传接口只负责落文件、建任务、返回任务 ID,转换丢进队列由消费者慢慢跑,前端轮询任务状态拿预览地址。这样大文件不会拖垮 Web 线程,也天然支持失败重试。改造点集中在ConvertServiceImpl,把converter.convert的调用挪到消费者里,Controller 返回值从 url 换成 taskId。

第二步是加缓存。同一份文档被反复预览很常见,用源文件的 MD5 做 key,转换前先查成品 PDF 是否存在,命中直接返回。这一步能砍掉大量重复转换,实现上在convertToPdf开头插一段查缓存逻辑即可,注意 MD5 要基于文件内容而不是文件名。

第三步是转换器插件化。把DocumentConverter接口暴露成 SPI,新格式(比如 OFD、CAD 图纸)由各业务线自己实现并注册,核心服务不感知具体格式。配合配置中心动态开关某个转换器,出问题能快速下线。

验证改造是否成功,我习惯用一组固定样本跑回归:纯文字 docx、带合并单元格的 xlsx、带图表的 pptx、超大文件、损坏文件各一份,每次改动后全量跑一遍,看成功率、耗时、产物页数是否稳定。这套样本比任何单元测试都直观。

提示:改造前先把原始版本的转换结果存一份基线,后面每次调整参数或升级 LibreOffice 都拿它对比,排版漂移能第一时间发现。

从那以后我每次接文档转换类需求,都强制先跑一遍「字体 + 并发 + 超时」三件套验证,再谈功能开发,不然上线后半夜被叫起来修乱码的滋味真不好受。希望帮到你。

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

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

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

立即咨询