简介面向Java开发者的文档在线预览解决方案解决doc、xls、txt及图片等格式转为PDF并在Web端浏览的问题。压缩包内含6个文件以5个Java源码文件和1个txt说明文档为主总体积仅7KB适合需要快速集成Apache POI与iTextPDF的开发者参考txt内对jar包版本做了简要标注方便核对依赖。源码按功能拆分为Word转PDF、Excel转PDF、文件浏览、字体支持及HTML转换等工具类覆盖HSSF/XSSF读写Excel、中文字体嵌入、iText文本与样式处理、文件上传下载与访问控制等关键环节也便于理解Document、Paragraph、Table、Image在PDF版面中的组合用法可直接借鉴或嵌入Spring MVC/Servlet项目。已有2394人学习下载借助该包可快速理解Excel新旧格式兼容、PDF布局生成及在线预览链路其中对Excel旧版XLS与新版XLSX的读写差异也有直观示例包内代码量不大但模块边界清楚适合学习、改造后用于企业内部文件管理系统或合同在线预览场景。1. 项目定位一个纯 Java 的 Office 文档转 PDF 预览服务做企业应用开发的人应该都躲不过这个需求用户上传一份 doc 合同或者 xls 报表不想下载到本地就希望直接在浏览器里打开看内容、翻页、搜索关键词。前端直接去解析 doc、xls 非常痛苦而 PDF 在浏览器里几乎天然支持预览所以最成熟的做法就是把 Office 文件在后端转换成 PDF再给前端一个预览地址。这个项目解决的就是这一环——用 Java 实现 doc、docx、xls、xlsx 向 PDF 的转换并提供一套完整的在线浏览接口。它不处理复杂的权限控制也不做在线编辑专注「转换 预览」这一条链路。适合正在做 OA 办公、文件管理、企业网盘系统的 Java 开发者或者手头正好要交付一个文件在线预览模块直接拿这个工程改一改就能用。2. 整体架构与选型POI 解析、PDF 绘制的纯 Java 方案2.1 技术选型为什么不用 JODConverter 而选纯 Java做 Office 转 PDF市面上常见有三条路。第一条是 JODConverter 搭配 LibreOffice转换效果最好复杂排版还原度极高但它要求运行环境里装一个完整的 LibreOffice 程序内存占用轻松超过 500M容器化部署时还得单独做一层镜像遇到中文字体缺失更是让人头疼。第二条是直接用 Aspose接口封装得很舒服但它是商业授权公司没有预算基本不用考虑。剩下就是这条纯 Java 路线用 POI 解析 Office 文件结构再用 PDFBox 之类的生成库把内容画到 PDF 上。这个项目选的就是第三条路。好处非常明显没有外部进程依赖一个 JVM 就能跑起来打 Docker 镜像也简单部署成本无限接近于零坏处也真实存在——对于 Word 里特别复杂的样式比如文本框、艺术字、复杂的页眉页脚还原度会打折扣。不过对于合同、通知、报表这类以正文和表格为主的文件效果是够用的。我见过不少生产环境就是这么跑的外面套一层队列做异步转换照样扛得住日常办公流转。选型权衡下来这个项目里真正干活的组件就两个POI 负责读 doc、docx、xls、xlsxPDFBox 负责把读出来的内容画到 PDF 上。剩下的 Controller、Service、缓存都是围绕这两个核心做组织。2.2 工程目录结构一眼看懂的 Maven Web 分层项目是一个标准的 Maven Web 工程包结构很清晰拿到手不需要太多解释就能找到对应模块office-preview ├── pom.xml ├── src/main/java │ └── com/example/officepreview │ ├── OfficePreviewApplication.java # Spring Boot 启动类 │ ├── controller │ │ ├── FileConvertController.java # 上传转换接口 │ │ └── PreviewController.java # PDF 预览访问接口 │ ├── service │ │ ├── OfficeConvertService.java # 转换编排 缓存判断 │ │ └── impl │ │ └── OfficeConvertServiceImpl.java │ ├── converter │ │ ├── WordToPdfConverter.java # Word 转 PDF 核心 │ │ └── ExcelToPdfConverter.java # Excel 转 PDF 核心 │ ├── cache │ │ └── PdfCacheManager.java # 文件缓存映射 │ └── util │ ├── FileTypeUtils.java # 扩展名校验 │ └── CellValueUtils.java # Excel 单元格取值 ├── src/main/resources │ ├── application.yml # 端口、文件目录配置 │ └── fonts │ └── simsun.ttf # 中文字体避免 PDF 乱码 └── src/main/webapp └── index.html # 演示预览页面controller 层只做 HTTP 出入参转换真正的逻辑在 service 和 converter 里。converter 包是两个转换器的核心实现相互独立想替换某个转换逻辑不影响其他模块。cache 里放了一个简单的内存映射记录文件 hash 和 PDF 文件名的对应关系。fonts 目录下放中文字体这一步是 PDF 转换能不能出中文的关键后面踩坑章节会详细说。为什么把转换器单独拆成一个包因为在实际改造时很可能要把 Word 转换和 Excel 转换替换成不同实现比如某个格式换成调用 LibreOffice接口隔离好一点替换成本就低很多。这个结构对于中等规模的项目来说是合理的。2.3 核心依赖pom.xml 里真正起作用的三样东西pom.xml 里依赖看着不少但和转换链路直接相关的其实就下面这几个dependency groupIdorg.apache.poi/groupId artifactIdpoi/artifactId version5.2.3/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.3/version /dependency dependency groupIdorg.apache.pdfbox/groupId artifactIdpdfbox/artifactId version2.0.27/version /dependencypoi 这个包管的是老格式 xls 和 docpoi-ooxml 管的是 xlsx 和 docx两个都得引。PDFBox 负责生成 PDF 文件2.0.x 版本目前是生产环境的主力版本API 稳定资料也好查。这里提醒一句版本问题POI 的 minor 版本之间偶尔会有 API 调整4.x 和 5.x 之间就有几处方法签名变化。这个项目用的是 5.x如果你在别处看到教程里用的 4.x 代码跑不起来先检查是不是 POI 版本差异导致的。Spring Boot 版本用 2.7.x 就行不需要追新。JDK 建议 8 以上POI 5.x 要求 JDK 8 起步实际用 11 或 17 都没问题。转换本身是 CPU 密集操作对 JDK 版本不敏感。3. 核心模块拆解一个 Office 文件从上传到生成 PDF 的完整链路3.1 转换入口扩展名校验 转换服务编排用户上传文件之后第一步不是马上转换而是先做类型判断。这个项目支持的格式是固定的四个doc、docx、xls、xlsx通过扩展名做初筛不合法的直接抛异常。用扩展名判断虽然简单粗暴但对于一个内部预览系统来说已经够用因为浏览器上传时给的 Content-Type 经常不准反而扩展名最可靠。RestController RequestMapping(/api/files) public class FileConvertController { private static final SetString SUPPORTED_EXTS Set.of(doc, docx, xls, xlsx); private final OfficeConvertService convertService; public FileConvertController(OfficeConvertService convertService) { this.convertService convertService; } PostMapping(/convert) public MapString, Object convert(RequestParam(file) MultipartFile file) throws Exception { String originalName file.getOriginalFilename(); String ext originalName.substring(originalName.lastIndexOf(.) 1).toLowerCase(); if (!SUPPORTED_EXTS.contains(ext)) { throw new IllegalArgumentException(不支持的文件类型: ext); } String pdfFileName convertService.convert(file); String previewUrl /preview/ URLEncoder.encode(pdfFileName, StandardCharsets.UTF_8); return Map.of(code, 0, previewUrl, previewUrl); } }这个接口只有一个入参file返回的是一个 map里面带previewUrl。有几个值得注意的细节扩展名取出来之后强制转小写因为 Windows 上传的文件名经常是.DOC、.XLSX这种大写不转小写后面匹配就翻车了URLEncoder.encode是为了处理返回的 URL 里可能出现的中文或者特殊字符PDF 文件名如果直接用文件原名很容易因为编码问题导致前端打开 404。Map.of是 JDK 9 以后才有的写法如果项目还锁在 JDK 8改成new HashMap()手动 put 就行。这种返回结构没有用统一的 Result 包装类但为了方便前端判断code 0 约定为成功非 0 在全局异常处理器里统一转成 JSON 错误信息。3.2 转换编排hash 去重 落盘 调用转换器进入 Service 层之后核心逻辑是三步算文件 hash、查缓存、决定要不要真的执行转换。同一份文件上传两次第二次直接命中缓存返回不重复消耗 CPU。这里用 MD5 做文件指纹对预览场景来说碰撞概率可以忽略不计。Service public class OfficeConvertServiceImpl implements OfficeConvertService { private final PdfCacheManager cacheManager; private final WordToPdfConverter wordConverter; private final ExcelToPdfConverter excelConverter; public OfficeConvertServiceImpl(PdfCacheManager cacheManager, WordToPdfConverter wordConverter, ExcelToPdfConverter excelConverter) { this.cacheManager cacheManager; this.wordConverter wordConverter; this.excelConverter excelConverter; } Override public String convert(MultipartFile file) throws Exception { // 1. 计算文件内容 hash用作缓存 key 和生成的 PDF 文件名 String fileHash DigestUtils.md5DigestAsHex(file.getBytes()); String cached cacheManager.get(fileHash); if (cached ! null) { return cached; } // 2. 把上传的临时文件落盘后续 POI 读取需要文件路径 Path tempFile Files.createTempFile(upload_, _ file.getOriginalFilename()); file.transferTo(tempFile.toFile()); // 3. 根据扩展名分派到不同的转换器 String ext getExtension(file.getOriginalFilename()).toLowerCase(); String pdfName fileHash .pdf; Path pdfPath Paths.get(cacheManager.getPdfDir()).resolve(pdfName); if (doc.equals(ext) || docx.equals(ext)) { wordConverter.convert(tempFile.toString(), pdfPath.toString()); } else if (xls.equals(ext) || xlsx.equals(ext)) { excelConverter.convert(tempFile.toString(), pdfPath.toString()); } // 4. 转换成功之后写入缓存临时文件删除 cacheManager.put(fileHash, pdfName); Files.deleteIfExists(tempFile); return pdfName; } }这一步有个非常容易踩的坑DigestUtils.md5DigestAsHex(file.getBytes())在大文件场景下会把整个文件读进内存50MB 的 Excel 文件一次读取也要占不少堆。生产环境里一般改成InputStream流式计算 MD5一边读一边更新摘要。项目里直接用 getBytes 是因为演示场景下文件普遍不大但你自己改造的时候记得换成流式。临时文件用Files.createTempFile创建注意这里保留了原始文件名后缀比如upload_1234567890_合同.docx。保留后缀不是强迫症是因为 POI 在读文件时除了内容本身有时候还会根据文件名推断类型如果临时文件没有扩展名个别版本会解析出错。转换成功之后临时文件立刻删除不为服务器留垃圾。3.3 Word 转 PDF段落遍历 自动分页Word 转换的核心是读 docx 里的段落把每段文字画到 PDF 上。docx 本身就是一堆 XML 打包的 zipPOI 的 XWPF 模块把这些 XML 暴露成段落、表格、图片的对象模型用起来比直接解析 XML 省心太多。public class WordToPdfConverter { private static final float PAGE_WIDTH 595f; private static final float PAGE_HEIGHT 842f; private static final float MARGIN 40f; public void convert(String docxPath, String pdfPath) throws Exception { try (InputStream in Files.newInputStream(Paths.get(docxPath)); XWPFDocument document new XWPFDocument(in); PDDocument pdf new PDDocument()) { PDPage page new PDPage(new PDRectangle(PAGE_WIDTH, PAGE_HEIGHT)); pdf.addPage(page); PDPageContentStream content new PDPageContentStream(pdf, page); // 字体只加载一次重复加载会导致同一个 PDF 内嵌多份字体文件体积暴涨 PDType0Font font PDType0Font.load(pdf, new File(/data/fonts/simsun.ttf)); float yCursor PAGE_HEIGHT - MARGIN; for (XWPFParagraph paragraph : document.getParagraphs()) { String text paragraph.getText().trim(); if (text.isEmpty()) { continue; } // 从段落的第一个 run 里拿字号拿不到就默认 12pt float fontSize 12f; if (!paragraph.getRuns().isEmpty()) { int runSize paragraph.getRuns().get(0).getFontSize(); if (runSize ! -1) { fontSize runSize; } } float lineHeight fontSize * 1.5f; // 剩余空间不够写一行就换页 if (yCursor - lineHeight MARGIN) { content.close(); page new PDPage(new PDRectangle(PAGE_WIDTH, PAGE_HEIGHT)); pdf.addPage(page); content new PDPageContentStream(pdf, page); yCursor PAGE_HEIGHT - MARGIN; } content.beginText(); content.setFont(font, fontSize); content.newLineAtOffset(MARGIN, yCursor); content.showText(text); content.endText(); yCursor - lineHeight; } content.close(); pdf.save(pdfPath); } } }这段代码的逻辑不复杂但有几个点值得细看。首先是PDType0Font.load放在循环外面这是有讲究的——如果放在每段循环里加载字体生成的 PDF 会把同一个字体文件嵌入多次一个 12MB 的字体能给你重复塞十几次文件直接膨胀到上百 MB。这一点我在实际项目里是吃过大亏的。其次是分页逻辑。PDF 的坐标系原点在左下角Y 轴向上增长所以yCursor从页面顶部往下走每次减去行高。当剩余高度不足一行时就新建页面。用content.close()关掉当前页的流再重新创建是为了确保前一页的内容完整写入 PDF 中这是 PDFBox 的一个小坑不 close 的话最后一页内容经常是空的。文本坐标用的是newLineAtOffset(MARGIN, yCursor)PDFBox 里beginText()和showText()之间必须先设置起始坐标这个坐标的原点是页面左下角。对于从左上角习惯思考的开发者来说第一次写 PDF 坐标很容易把 Y 轴方向搞反。3.4 Excel 转 PDFsheet 遍历 画格子 写文本Excel 转换比 Word 麻烦一些因为除了文字还要尽量还原表格的格子结构。这个项目的做法是把每个 sheet 渲染成一页 PDF先算出 sheet 的总列数然后按照固定宽度把一个 A4 页面划分成网格逐行逐列地画边框、写单元格内容。public class ExcelToPdfConverter { public void convert(String xlsxPath, String pdfPath) throws Exception { try (Workbook workbook WorkbookFactory.create(new File(xlsxPath)); PDDocument pdf new PDDocument()) { for (int s 0; s workbook.getNumberOfSheets(); s) { Sheet sheet workbook.getSheetAt(s); PDPage page new PDPage(PDRectangle.A4); pdf.addPage(page); PDPageContentStream content new PDPageContentStream(pdf, page); PDType0Font font PDType0Font.load(pdf, new File(/data/fonts/simsun.ttf)); int lastRow sheet.getLastRowNum(); int maxCols getMaxCols(sheet); float colWidth (PDRectangle.A4.getWidth() - 80f) / Math.max(maxCols, 1); float rowHeight 18f; float x0 40f; float y0 PDRectangle.A4.getHeight() - 60f; for (int r 0; r lastRow; r) { Row row sheet.getRow(r); if (row null) { continue; } for (int c 0; c maxCols; c) { Cell cell row.getCell(c); String value CellValueUtils.getCellValue(cell); // 画出这个格子的边框 content.addRect(x0 c * colWidth, y0 - r * rowHeight, colWidth, rowHeight); content.stroke(); // 有内容才写文本防止大量无意义文本绘制 if (value ! null !value.isEmpty()) { content.beginText(); content.setFont(font, 9f); content.newLineAtOffset(x0 c * colWidth 4f, y0 - r * rowHeight - 12f); content.showText(value.length() 20 ? value.substring(0, 20) ... : value); content.endText(); } } } content.close(); } pdf.save(pdfPath); } } }WorkbookFactory.create是 POI 提供的一个统一入口它会根据文件头自动识别是 xls 还是 xlsx不需要自己判断格式。getMaxCols的作用是遍历整个 sheet 找出最大的列数因为每一行的有效列数可能不同如果不统一列数画出来的格子会对不齐。单元格取值专门抽了一个工具类这个设计在真实项目里很有必要。Excel 里的日期格、数字格直接调toString拿到的是一串序列化数字比如日期显示成 45123必须通过DataFormatter或者手动判断 cellType 才能拿到用户看到的样子。工具类代码如下public class CellValueUtils { private static final DataFormatter FORMATTER new DataFormatter(); public static String getCellValue(Cell cell) { if (cell null) { return ; } // DataFormatter 会按照单元格的格式设置来输出日期、百分比都正常 return FORMATTER.formatCellValue(cell); } }长文本截断到 20 个字符加省略号算是这个项目的一个妥协。真实的 Excel 里一个单元格塞几百字很常见如果完整画到 PDF 上文本会超出格子边界和相邻内容叠在一起很难看。见过一些生产项目用 PDFBox 的文本自动换行来临时避让但效果也一般因为一个格子里的文字太长终究要跨行展示才能看全这个可以放在后面改造思路里再讲。3.5 缓存管理用 hash 做 key不做重复转换转换是 CPU 密集型操作同一个文件转两次是纯浪费。项目里用一个简单的 map 做内存缓存key 是文件内容的 MD5value 是 PDF 的文件名。这样设计还有一个额外的好处PDF 文件名直接用 hash天然不会重复也避免了中文文件名在 URL 里编来编去的麻烦。Component public class PdfCacheManager { private final ConcurrentHashMapString, String cache new ConcurrentHashMap(); private String pdfDir /data/preview; public String get(String fileHash) { return cache.get(fileHash); } public void put(String fileHash, String pdfName) { cache.put(fileHash, pdfName); } public String getPdfDir() { return pdfDir; } }ConcurrentHashMap 在并发场景下不会出问题多个请求同时转换同一个文件时最多是多算几次 hash不会写坏数据。当然这个缓存是不持久化的服务一重启就没了好在对预览场景来说问题不大顶多就是同文件第一次访问慢一点。如果要持久化常见的做法是在缓存里加一层文件存在性判断pdfPath存在就直接返回不存在才执行转换相当于用磁盘做了一个隐式缓存。4. 在线预览实现URL 生成、PDF 读取与前端接入4.1 从 PDF 文件名到可访问的预览 URL转换完成后返回给前端的不是 PDF 文件的物理路径而是一个经过编码的预览 URL。这么做是为了把内部的物理路径藏起来避免用户猜到服务器目录结构。预览 URL 长这样/preview/7f2a3b9c8d4e5f6a7b8c9d0e1f2a3b4c.pdf文件名是 MD5 hash天然不带特殊字符。但 URL 编码这个动作还是保留了因为如果后续改造把文件名改成原始文件名加 hash 的拼接形式比如合同_2024_v2_7f2a3b9c.pdf没有编码的话空格和下划线还好中文直接放进 URL 就会出问题。生成 URL 的代码在 Controller 里String pdfFileName convertService.convert(file); String previewUrl /preview/ URLEncoder.encode(pdfFileName, StandardCharsets.UTF_8);注意只在文件名这一段做编码/preview/前缀不要编码。用了StandardCharsets.UTF_8而不用UTF-8字符串算是个人写代码的习惯——字符串编码名写错运行时才报错用常量类编译期就能发现拼写问题。4.2 PDF 读取接口inline 输出而不是下载浏览器能直接预览 PDF靠的是响应头里Content-Disposition: inline。如果写成attachment浏览器会直接弹下载框而不是展示预览这是很多第一次做预览功能的人最容易搞错的地方。项目里的读取接口设置了 PDF 的 Content-Type 和 inline 标识RestController RequestMapping(/preview) public class PreviewController { private final PdfCacheManager cacheManager; public PreviewController(PdfCacheManager cacheManager) { this.cacheManager cacheManager; } GetMapping(/{fileName}) public ResponseEntityResource preview(PathVariable String fileName) throws IOException { // 前端传过来的文件名是 URL 编码过的先解码再拼路径 String decodedName URLDecoder.decode(fileName, StandardCharsets.UTF_8); Path pdfPath Paths.get(cacheManager.getPdfDir()).resolve(decodedName); if (!Files.exists(pdfPath)) { return ResponseEntity.notFound().build(); } Resource resource new FileSystemResource(pdfPath); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_TYPE, application/pdf) .header(HttpHeaders.CONTENT_DISPOSITION, inline; filename\ decodedName \) .body(resource); } }URLDecoder.decode是 4.1 节URLEncoder.encode的对应操作前端拿到 URL 后访问路径参数天然是被编码过的所以读取时必须先解码再拼物理路径。这里有一个安全点值得注意decodedName拼进路径后理论上可能出现../目录穿越虽然 MD5 文件名不会有这个问题但如果你后续改成拼接原始文件名一定要加一层路径校验比如禁止包含..或者/否则别人构造一个恶意 URL 就能读服务器上的任意文件。ResponseEntityResource是 Spring 里返回文件流的标准姿势FileSystemResource会把文件内容以流的方式写给浏览器不会一次性把整个 PDF 加载到 JVM 内存。一个 100MB 的 PDF 在这种方式下也能平稳输出。4.3 前端接入一个 iframe 就够后端接口齐了前端反而最简单。PDF 在浏览器里本来就是原生支持预览的不需要引入 PDF.js 之类的库。演示页面的核心就是一个 iframe!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleOffice 在线预览/title /head body form iduploadForm input typefile namefile accept.doc,.docx,.xls,.xlsx button typesubmit上传并预览/button /form iframe idpreviewFrame stylewidth: 100%; height: 800px; border: 1px solid #ddd;/iframe script document.getElementById(uploadForm).addEventListener(submit, async function (e) { e.preventDefault(); const formData new FormData(this); const resp await fetch(/api/files/convert, { method: POST, body: formData }); const result await resp.json(); if (result.code 0) { document.getElementById(previewFrame).src result.previewUrl; } else { alert(转换失败 result.message); } }); /script /body /htmlaccept属性限定文件选择框只能选 Office 文档这是前端第一层拦截可以筛掉大部分明显不合法的文件类型给后端减压。iframe 的 src 直接指向后端返回的 previewUrl浏览器会自动识别 PDF 类型并进入内置阅读器缩放、翻页、搜索都是浏览器自己的能力项目一行代码都不用写。一个常见的坑是系统里如果做了登录鉴权iframe 加载/preview/xxx.pdf时浏览器会直接发一个 GET 请求如果这个 URL 没有走统一的登录过滤器就会出现预览空白页。解决办法是在 PDF 读取接口上单独放一个过滤器允许带 token 的请求通过或者把 token 放在请求头里由 fetch 手动加载而不是直接丢给 iframe。这些属于接入时的环境适配问题。5. 避坑指南转换和预览路上我踩过的五个坑5.1 转出来的 PDF 中文全是方块现象Word 转换后 PDF 里的中文全部变成一个个空心方块英文和数字正常。原因PDFBox 默认字体是 Helvetica这是一套标准的 PDF 内置字体只支持英文和拉丁字符压根不认识中文。如果加载字体时偷懒没指定中文字体或者指定的字体文件路径不对showText 遇到中文就全部渲染成占位方块。另外还有一个隐蔽的坑有些教程让你每画一段就PDType0Font.load一次字体虽然没乱码但生成的 PDF 体积暴涨。解决在项目里放一份真正的 TTF 中文字体比如 simsun.ttf在转换器初始化时加载一次并复用不要放在循环里。检查字体文件本身能正常打开很多网上随便找的字体文件是残缺的加载时不报错渲染时才出问题。我一般会在项目里放一个自测用例把包含「中文测试」四个字的 docx 转成 PDF跑通了再往下做。5.2 老版 doc 转出来丢内容、格式乱现象.doc格式的文件转 PDF 后文字少了一部分段落缩进全没了表格交叉错乱。原因POI 对 docxOOXML的支持很完善但对老版 docOLE2的支持用的是 HWPF 模块这个模块多年没有实质更新很多格式信息根本读不出来。PJ 项目里如果用了不存在的 API编译期就能发现但 HWPF 的问题是它读得出来只是读不全属于静默丢数据排查起来要命。解决最省事的方案是让用户在源头上转成 docx很多 OA 系统都是这么做的如果做不到就得接受还原度有限这个现实。这个项目里我没走边路直接按原样处理所以遇到老 doc 文件预览效果一般属于已知限制。真要兼容老格式建议在转换前用办公室软件批量转一次或者直接上 JODConverter 方案。5.3 大 Excel 转换时内存溢出现象一个几十 MB 的 xls 传上去转换接口直接抛出 OutOfMemoryError整个应用跟着卡死。原因POI 读取 Excel 有两种模式USermodel 把整个工作簿加载进内存Sheet 上的所有行、单元格、样式全是 Java 对象。一份几万行的大表轻松吃掉几百 MB 堆内存。如果项目用的是 JVM 默认堆大小几十 MB 的 Excel 就能触发频繁 GC 甚至直接 OOM。这是最容易把测试环境搞挂的坑。解决项目当前的实现用WorkbookFactory.create走的是 USermodel全量加载。要规避这个问题常规做法有三选一限制上传文件大小超过 20MB 直接拒绝并提示用户拆分文件转换放到消息队列里异步执行即使 OOM 也不会拖垮 HTTP 线程池改用 SAX 事件模式逐行读取。第三种方案改动量大为了一个预览功能上 SAX 不一定划算我一般建议前两种组合用。5.4 预览显示的一直是旧文件现象用户重新上传了一个同名文件转换成功后打开预览看到的还是之前那份旧 PDF 的内容。原因浏览器的 HTTP 缓存把/preview/7f2a3b9c.pdf这个 URL 缓存了。如果文件名没有变化浏览器直接使用本地缓存的 PDF不会重新向服务器发起请求。这个项目用 MD5 做文件名理论上不会重名但如果你在改造时换成了带原始文件名的格式很容易踩中。解决两种思路可选。一是在预览 URL 后面拼一个版本参数比如/preview/xxx.pdf?v20250101URL 变化了浏览器自然不命中缓存二是把 PDF 文件名里的 hash 从内容 MD5 改成「内容 MD5 时间戳」保证同内容二次转换也是新文件名。第一种简单第二种彻底。我实际做的时候选了第二种顺带把旧文件定期清理也做了。5.5 Excel 合并单元格转出来错位缺边框现象原始 Excel 里有合并单元格的区域转成 PDF 后内容只出现在左上角格子里其他格子空白边框线也是断的。原因POI 遍历单元格时被合并的单元格区域里只有左上角的 cell 有值其他格子的 cell 对象是 null。代码里对每个格子都独立画边框、写文本合并区域没有做特殊处理自然就画出一堆零散的格子。这里还有一个隐藏问题合并单元格下边和右边的边框在 Excel 里是合并区域的外观属性遍历时根本读不到。解决转换前先取sheet.getMergedRegions()拿到所有CellRangeAddress合并区域把它们存进一个集合。遍历到格子时判断当前行列是否命中某个合并区域如果是被合并的格子就跳过是左上角的格子就按整个合并区域的宽高来画边框和文本。核心就是给转换器加一个合并区域处理逻辑ListCellRangeAddress mergedRegions sheet.getMergedRegions(); boolean isMergedCell(CellRangeAddress region, int row, int col) { return region.getFirstRow() row row region.getLastRow() region.getFirstColumn() col col region.getLastColumn(); }这个方法把所有合并区域先圈出来再逐格判断不算最优解但直观好读懂。有经验的开发者可以把合并区域倒排成 Mapkey 是行列号value 是合并区域信息查起来更快不过对预览场景这个量级的循环无所谓的。6. 改造思路从演示工程到能抗生产的预览服务这个项目当前的设计足够跑通完整流程但要说直接上生产还有几块硬骨头要啃。先说异步转换。当前的/api/files/convert接口是同步的文件一传上来就当场转换一个 20MB 的 Excel 转换可能要花十几秒HTTP 连接早就超时了。常见做法是上传后立刻返回一个 taskId转换丢进线程池或者消息队列前端拿到 taskId 轮询/api/tasks/{taskId}查状态转好了再拿预览地址。改造时只需要把 Controller 里的同步调用换成提交任务Service 里加一个任务状态表代码改动量不大收益却很明显。其次是转换质量。如果项目里遇到的 Office 文件格式复杂POI 画 PDF 的方式确实力不从心。我的建议是在 converter 包下面加一个新的实现内部调用 JODConverter LibreOffice通过命令行或 socket 方式把 docx、xlsx 转成 PDF。JODConverter 转出来的 PDF 还原度接近原文件代价是部署环境多一个 LibreOffice 依赖Docker 镜像里的字体、locale 都要配好。这个方案可以作为 POI 方案的兜底接口层保持convert(String inputPath, String outputPath)不变用策略模式按文件类型或者按配置切换具体的转换器实现。还有一个大的改造方向是把 PDF 文件推到 OSS 上。预览服务如果只有单机文件存在本地没问题一旦要做多实例部署每台机器各存一份 PDF用户访问被负载均衡到没有该文件的机器上就 404。把转换完的 PDF 上传到对象存储预览 URL 直接指向 OSS文件管理、CDN 加速、过期清理都交给对象存储来处理本地只放临时文件。这一步配合哈希目录做分级存储比如按 MD5 前两位建子目录文件多时不会出现单目录上万文件的情况。最后说个我自己养成的小习惯每次改完这类预览服务一定强制走一遍「中文文件名上传 → 预览 → 刷新页面验证缓存 → 再传同文件验证命中」四个动作全部通过才算验收。这套流程帮我拦下过两次低级回归一次是预览 URL 编码漏了一次是文件名带空格时 PDF 读取接口 404。这个项目本身是个良好的起点代码读懂了、坑都踩过了你在这个基础上做的每一次改造都会比从零开始轻松很多希望帮到你。本文还有配套的精品资源点击获取