Word转PDF再转图片:在线预览方案与Linux部署实践
文档处理这块只要涉及到在线预览四个字基本就绕不开把 Word 转成 PDF、再把 PDF 按页拆成图片这条路。我最早接触这个需求是在一个合同管理类的后台系统里业务方要求用户上传一份 Word 合同页面上直接能翻页看不能下载原文件也不能让用户装任何插件。当时第一反应是前端直接渲染 docx试了几个方案要么字体乱掉要么复杂表格直接崩最后还是老老实实回到服务端转换这条路上来。这套方案的核心链路其实就三步Word 转 PDF、PDF 按页转图片、前端按页展示。听起来简单但真到 Linux 服务器上部署坑一个接一个字体缺失、中文乱码、转换超时、内存溢出每一个都能让你调半天。下面我就把这套流程从选型到落地完整拆一遍包括我踩过的那些坑和最后稳定运行的配置。1. 为什么最终选了Word→PDF→图片这条链路1.1 直接前端渲染 docx 的几种方案为什么被我放弃了一开始我最想走的路是前端直接解析 docx。毕竟这样服务端压力小不用装一堆转换工具理论上也更快。我试过几个主流的 JS 库原理基本都是把 docx 当成一个 zip 包解开读取里面的 XML然后映射成 HTML 标签渲染出来。问题出在映射这两个字上。Word 的排版模型和 HTML 的排版模型根本不是一回事。Word 里一个段落可以精确到磅值的行距、段前段后间距、首行缩进字符数还有分栏、文本框、艺术字、页眉页脚这些结构。你把它转成 HTML浏览器根本不认这些概念只能近似模拟。我实测下来纯文字段落还凑合一旦遇到下面这几种情况就彻底没法看复杂表格合并单元格、嵌套表格、单元格内换行渲染出来行列全乱中文字体Word 里用的宋体、仿宋、黑体前端没有对应字体文件就直接回退成默认字体字号和行距全变图片和文字混排浮动图片的位置完全对不上分页Word 里的分页符在 HTML 里没有对应概念所有内容挤成一长条提示如果你的文档格式极其简单就是纯文字加简单列表前端渲染确实能省事。但只要涉及正式合同、报告、公文这类排版要求高的文档别犹豫直接走服务端转换。还有一个隐性问题是安全性。前端渲染意味着原始 docx 文件要传到浏览器用户通过开发者工具就能拿到文件流。对于要求只能看不能下载的场景这条路直接堵死。1.2 PDF 作为中间格式的不可替代性既然前端渲染走不通就得在服务端把 Word 转成一种版式固定的格式。PDF 就是这个中间格式的最佳选择原因很直接PDF 的设计目标就是所见即所得它在任何设备上渲染出来的效果都一致字体、位置、分页全部固化在文件里。Word 转 PDF 这一步本质上是把流式排版转成固定排版。Word 文档本身是流式的内容会根据页面大小、字体可用性动态调整而 PDF 一旦生成每一页的内容位置就定死了。这个转换过程由转换引擎完成它会按照 Word 里记录的页面设置纸张大小、页边距来分页把每个元素定位到精确坐标。转成 PDF 之后其实已经可以满足大部分预览需求了。前端用 pdf.js 这类库就能直接渲染 PDF。那为什么还要再转成图片这就涉及到第二个决策点。1.3 从 PDF 再转图片解决的是展示可控性问题PDF 直接在前端渲染技术上完全可行但有几个现实约束让我最终选择了转图片第一是移动端兼容性。pdf.js 在移动端浏览器上的表现参差不齐尤其是低端安卓机渲染大 PDF 时卡顿明显滚动缩放体验很差。而图片是浏览器原生支持最好的格式任何设备都能流畅显示。第二是防复制需求。PDF 里的文字是可以选中复制的哪怕你禁用了右键用户还是能通过其他方式提取文字。转成图片之后文字变成像素复制这条路就断了。对于合同、试卷这类内容这个特性很重要。第三是展示样式统一。图片的尺寸、清晰度完全由服务端控制前端只需要按顺序排列就行不用处理 PDF 渲染的各种兼容性问题。当然转图片也有代价文件体积变大清晰度和体积需要权衡长文档会产生很多张图片。这些后面会详细讲怎么优化。方案排版还原度移动端体验防复制服务端压力实现复杂度前端渲染 docx低中无无中前端渲染 PDF高差弱中中PDF 转图片展示高好强高较高这张表是我当时做选型时的真实对比最后一行虽然服务端压力最大但综合体验最好所以定了这个方案。2. Linux 环境下 Word 转 PDF 的工具选型与踩坑2.1 LibreOffice 无头模式免费方案里的最优解Linux 上做 Office 文档转换绕不开 LibreOffice。它有个无头模式headless就是不启动图形界面直接在命令行完成转换。这是免费方案里兼容性最好的对 docx、doc、xlsx、pptx 都支持。基本命令长这样libreoffice --headless --convert-to pdf --outdir /output/path /input/path/document.docx看起来很简单但直接这么用十有八九会出问题。我一个个说。第一个坑是并发问题。LibreOffice 默认会复用同一个用户配置目录如果你同时发起多个转换请求它们会抢同一个 profile导致转换失败或者进程卡死。解决办法是给每个转换任务指定独立的用户配置目录libreoffice --headless \ -env:UserInstallationfile:///tmp/lo_profile_${taskId} \ --convert-to pdf \ --outdir /output/path \ /input/path/document.docx这个taskId可以用 UUID 或者时间戳加随机数保证每次转换用的是独立目录。转换完成后记得把这个临时目录删掉不然 /tmp 会被塞满。第二个坑是进程不退出。有时候转换完成了但 LibreOffice 进程还挂在那里不释放。我一般的做法是加超时控制用timeout命令包一层timeout 120 libreoffice --headless \ -env:UserInstallationfile:///tmp/lo_profile_${taskId} \ --convert-to pdf \ --outdir /output/path \ /input/path/document.docx120 秒对于普通文档足够了超大文档可以适当放宽。超时后进程会被强制杀掉避免堆积。第三个坑是首次启动慢。LibreOffice 第一次运行要初始化配置可能要好几秒。如果你的服务是常驻的可以考虑预热或者接受第一次请求慢一点。2.2 字体问题中文乱码的根源和彻底解决办法这是 Linux 上做文档转换最经典的坑。服务器上默认只有少量英文字体Word 文档里用的宋体、黑体、仿宋、微软雅黑这些中文字体一个都没有。转换出来的 PDF 里中文要么变成方框要么变成乱码要么字体被替换成一种很难看的默认字体。解决思路很明确把中文字体装到服务器上。但具体操作有几个细节。首先字体文件从哪来。Windows 系统里的字体是有版权的不能随便拷到服务器商用。开源方案是装思源系列字体Source Han Sans / Source Han Serif或者文泉驿系列。这些字体覆盖了常用汉字效果也不错。如果业务方对字体有严格要求那就得走正规授权渠道获取字体文件。安装字体的步骤# 创建字体目录 mkdir -p /usr/share/fonts/chinese # 把 ttf 或 otf 字体文件拷进去 cp /path/to/fonts/*.ttf /usr/share/fonts/chinese/ # 刷新字体缓存 fc-cache -fv # 验证字体是否被识别 fc-list :langzh最后一条命令会列出所有支持中文的字体。如果能看到你装的字体说明成功了。但这里有个更深的问题字体名称映射。Word 文档里记录的字体名是宋体、SimSun这种而 Linux 上装的字体名可能是Source Han Serif或者Noto Serif CJK。LibreOffice 在转换时会做字体替换如果找不到宋体它会挑一个它认为最接近的。这个替换结果不一定符合预期。我的做法是配置字体替换规则。LibreOffice 支持通过配置文件指定替换关系但配置起来比较麻烦。更简单的办法是装一套和 Windows 字体名称对应的字体或者用 fontconfig 配置别名。在/etc/fonts/local.conf里加规则?xml version1.0? !DOCTYPE fontconfig SYSTEM fonts.dtd fontconfig match targetpattern test namefamilystring宋体/string/test edit namefamily modeassign bindingstrong stringSource Han Serif SC/string /edit /match match targetpattern test namefamilystringSimSun/string/test edit namefamily modeassign bindingstrong stringSource Han Serif SC/string /edit /match /fontconfig这样当转换引擎找宋体时fontconfig 会告诉它用Source Han Serif SC来代替。改完配置记得fc-cache -fv刷新。注意字体问题一定要在开发环境就验证好别等到上线才发现。我见过一个项目开发用的是 Mac测试环境是 Windows生产是 Linux结果只有生产环境中文乱码排查了半天才发现是字体没装。2.3 转换参数调优让输出更稳定LibreOffice 的转换命令还有一些参数值得关注。比如输出 PDF 的版本、是否嵌入字体、图片压缩质量等。默认参数在大多数情况下够用但有几个场景需要调整。如果 PDF 里要嵌入字体保证在任何设备上显示一致可以加--convert-to pdf:writer_pdf_Export:{EmbedStandardFonts:{type:boolean,value:true}}这种带过滤参数的写法。不过这个语法在不同版本里略有差异用之前最好在目标版本上测一下。另一个是转换超时。LibreOffice 处理超大文档比如几百页带大量图片的时可能很慢。除了用timeout命令控制还可以在应用层做异步处理把转换任务丢到队列里前端轮询状态。这个后面讲架构时会展开。2.4 备选方案对比什么时候该考虑商业库LibreOffice 免费、开源、兼容性好但它也有短板转换速度一般复杂文档偶尔会排版错位而且它是独立进程资源占用不低。如果你的业务对转换质量要求极高或者并发量很大可以考虑商业方案。市面上有一些专门做文档转换的库和 SDK转换质量和速度都比 LibreOffice 好但需要付费。选型时主要看几个维度支持的输入格式、排版还原度、并发能力、是否支持集群部署、授权费用。我的建议是先用 LibreOffice 跑起来如果实测下来质量和性能都能接受就没必要花钱。只有当 LibreOffice 确实满足不了需求时再考虑商业方案。毕竟免费方案省下的不只是钱还有部署和维护的复杂度。3. PDF 按页转图片工具选择与清晰度体积的平衡3.1 PDFBox 和 PDFRenderer 的取舍Java 生态里做 PDF 转图片主流选择有两个Apache PDFBox 和 PDFRenderer基于 ICEpdf 的那套。我两个都用过最后选了 PDFBox原因后面说。PDFBox 是 Apache 的项目纯 Java 实现不依赖本地库跨平台性好。它的核心 API 是PDFRenderer可以把 PDF 的某一页渲染成BufferedImage然后你再把这个 image 写成 jpg 或 png。基本代码长这样try (PDDocument document PDDocument.load(new File(input.pdf))) { PDFRenderer renderer new PDFRenderer(document); int pageCount document.getNumberOfPages(); for (int i 0; i pageCount; i) { // 参数 300 表示 DPI数值越大越清晰文件也越大 BufferedImage image renderer.renderImageWithDPI(i, 300); ImageIO.write(image, jpg, new File(page_ i .jpg)); } }PDFRenderer 那套org.apache.pdfbox 之外的另一个库在某些 PDF 上渲染质量更好尤其是矢量图形和特殊字体。但它的依赖更重而且社区活跃度不如 PDFBox。我实测下来对于 Word 转出来的 PDFPDFBox 的渲染质量完全够用所以选了它。3.2 DPI 到底设多少清晰度和体积的实测数据这是最需要权衡的参数。DPI 太低图片模糊文字看不清DPI 太高图片体积爆炸加载慢存储成本高。我做过一组实测用同一份 10 页的合同文档分别用不同 DPI 转成 JPG看单页平均体积和肉眼清晰度DPI单页平均体积文字清晰度适用场景72约 80KB屏幕上看略糊放大后明显模糊仅缩略图96约 130KB正常屏幕尺寸下可读快速预览150约 280KB清晰放大一倍仍可读通用预览200约 450KB很清晰放大两倍可读需要看清细节300约 900KB极清晰接近打印质量打印或高精度需求我最后选的是 150 DPI 作为默认值。这个数值下正常屏幕尺寸看文字很清晰放大一倍也能看清单页体积控制在 300KB 以内10 页文档总共 3MB 左右加载速度可以接受。如果业务方明确要求高精度再调到 200 或 300。提示DPI 不是越高越好。超过 300 之后肉眼几乎看不出差别但体积会继续线性增长。而且 PDFBox 渲染高 DPI 时内存占用很大300 DPI 渲染 A4 页面单张图片的 BufferedImage 就要占几十 MB 内存并发一高就容易 OOM。3.3 图片格式选择JPG 还是 PNGJPG 和 PNG 的区别大家都清楚JPG 有损压缩体积小适合照片PNG 无损体积大适合图形和文字。对于文档截图这种场景内容主要是白底黑字颜色数量少理论上 PNG 的压缩效率会很高。但实测下来PDFBox 渲染出来的图片用 PNG 存体积反而比 JPG 大不少。原因是 PDFBox 渲染时会有抗锯齿处理文字边缘产生了很多过渡色PNG 无损压缩这些过渡色效率不高。所以我的选择是 JPG质量参数设 0.85 左右。这个质量下文字边缘的锯齿几乎看不出来体积又比 PNG 小很多。如果文档里有大量彩色图表可以适当提高质量参数到 0.9。// 用 ImageIO 写 JPG 时控制质量 BufferedImage image renderer.renderImageWithDPI(i, 150); ImageWriter writer ImageIO.getImageWritersByFormatName(jpg).next(); ImageWriteParam param writer.getDefaultWriteParam(); param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); param.setCompressionQuality(0.85f); // 然后通过 ImageOutputStream 写出3.4 大文档的内存控制分页处理与流式写出前面提到高 DPI 渲染时单张图片内存占用很大。如果一份文档有 100 页你不可能一次性把所有图片都渲染到内存里再处理那样必 OOM。正确的做法是逐页处理渲染一页写出到磁盘或上传到对象存储然后释放这一页的 BufferedImage再处理下一页。这样内存占用始终只有一页的量级。for (int i 0; i pageCount; i) { BufferedImage image renderer.renderImageWithDPI(i, 150); // 写出这一页 writeImage(image, outputPath /page_ i .jpg); // 显式释放帮助 GC image.flush(); }image.flush()这行很关键它会释放 BufferedImage 占用的底层资源。虽然 Java 有 GC但大对象不及时释放GC 压力会很大容易触发 Full GC 导致服务卡顿。另外PDFBox 加载 PDF 时也有内存开销。如果 PDF 特别大可以考虑用MemoryUsageSetting配置让它把部分数据放到临时文件里减少内存占用PDDocument document PDDocument.load( new File(input.pdf), MemoryUsageSetting.setupTempFileOnly() );这个配置会让 PDFBox 把文档数据写到临时文件而不是全放内存。代价是读取速度慢一点但内存安全得多。4. SpringBoot 服务端的整体架构设计4.1 同步还是异步根据文档大小和并发量决定最简单的做法是同步处理用户上传 Word接口里直接转 PDF、转图片全部完成后返回图片地址列表。这个方案实现简单但有几个致命问题。第一是超时。一份几十页的文档转换加渲染可能要十几秒甚至更久。HTTP 请求等这么久网关和浏览器都可能超时。第二是并发。同步处理时每个请求都占着一个线程并发一高线程池就满了后面的请求全部排队。第三是用户体验。用户点了上传之后页面一直转圈不知道进度体验很差。所以我的方案是异步处理。用户上传后接口立即返回一个任务 ID后台异步执行转换前端拿着任务 ID 轮询进度。转换完成后前端拿到图片列表再展示。这个架构下需要几个组件任务表记录任务 ID、状态处理中/成功/失败、进度、结果图片路径列表线程池执行转换任务控制并发数临时文件管理转换过程中的中间文件要定期清理4.2 任务状态机的设计任务状态不能只有成功和失败两种中间态很重要。我设计的状态流转是这样的待处理(PENDING) → 转换中(CONVERTING) → 渲染中(RENDERING) → 成功(SUCCESS) → 失败(FAILED)为什么要区分转换中和渲染中因为这两个阶段耗时不同前端可以显示更精确的进度。Word 转 PDF 通常比较快PDF 转图片按页数来页数多就慢。前端可以根据阶段显示不同的提示文案。状态更新要注意并发安全。多个线程可能同时操作同一个任务用数据库的行锁或者乐观锁来保证一致性。我一般用乐观锁在任务表加一个 version 字段更新时带上版本号冲突就重试。4.3 文件存储本地临时目录还是对象存储转换过程中产生的文件上传的 Word、中间 PDF、最终的图片放哪里是个需要提前想清楚的问题。如果服务是单机部署放本地磁盘最简单。但要考虑几个问题磁盘空间够不够、文件什么时候清理、服务重启后文件还在不在。如果是多机部署或者要水平扩展本地磁盘就不行了因为用户可能这次请求打到 A 机器下次轮询打到 B 机器B 机器上没有文件。这种情况必须用共享存储比如对象存储或者网络文件系统。我的建议是图片最终存储用对象存储中间文件Word 和 PDF用本地临时目录。因为图片是要长期保留给前端访问的放对象存储方便管理和扩展中间文件转换完就没用了放本地临时目录转换完成后立即删除。对象存储的接入很简单主流云厂商都有 SDK。上传时注意设置合适的 Content-Type 和缓存策略。图片这种不变的内容可以设置较长的缓存时间减少重复请求。4.4 接口设计上传、查询、清理三个核心接口整个服务对外就三个接口上传接口接收 Word 文件创建任务返回任务 ID。PostMapping(/convert) public ResultString convert(RequestParam(file) MultipartFile file) { // 校验文件类型和大小 // 保存文件到临时目录 // 创建任务记录 // 提交异步任务 // 返回任务 ID }查询接口根据任务 ID 查询状态和结果。GetMapping(/status/{taskId}) public ResultTaskStatusVO status(PathVariable String taskId) { // 查询任务记录 // 返回状态、进度、图片列表如果完成 }清理接口删除任务相关的文件和记录。这个接口可以做成定时任务自动调用也可以暴露给前端手动触发。接口设计要注意幂等性。同一个文件重复上传应该返回同一个任务还是新任务我的做法是每次上传都创建新任务因为用户可能就是想重新转一次。但如果要节省资源可以用文件内容的 MD5 做去重相同内容直接返回已有任务的结果。5. 前端展示的几种实现方式和体验优化5.1 图片列表的懒加载和预加载策略前端拿到图片 URL 列表后最直接的展示方式就是按顺序渲染 img 标签。但一份文档可能有几十页一次性加载所有图片流量和内存都吃不消。懒加载是必须的。用 IntersectionObserver 监听图片是否进入视口进入时才设置 src 加载。这样用户滚动到哪页才加载哪页初始加载很快。const observer new IntersectionObserver((entries) { entries.forEach(entry { if (entry.isIntersecting) { const img entry.target; img.src img.dataset.src; observer.unobserve(img); } }); }); document.querySelectorAll(img[data-src]).forEach(img { observer.observe(img); });但纯懒加载有个问题用户快速滚动时图片还没加载出来会看到空白。所以需要预加载提前加载当前页前后各一两页的图片。我的做法是监听滚动位置计算当前可见页然后预加载相邻页。5.2 翻页模式 vs 滚动模式展示方式有两种一种是像 PDF 阅读器那样一页一页翻一种是像网页一样上下滚动。翻页模式的好处是每页独立加载压力小适合页数多、需要精确定位的场景。滚动模式的好处是浏览连贯适合快速浏览。我的做法是两种都支持让用户切换。默认用滚动模式因为实现简单体验也自然。翻页模式用轮播组件实现维护一个当前页索引切换时加载对应图片。翻页模式要注意预加载相邻页否则每次翻页都要等图片加载体验很卡。我一般预加载当前页的前一页和后一页这样用户翻页时基本是秒开。5.3 缩放和旋转让用户能看清细节图片展示时用户经常需要放大看细节。最简单的做法是点击图片弹出大图支持手势缩放。移动端可以用 pinch 手势PC 端可以用滚轮。旋转功能也有用尤其是扫描件方向不对的时候。旋转就是给 img 加 CSS transform记录旋转角度每次点击加 90 度。.rotated { transform: rotate(90deg); transform-origin: center center; }旋转后图片的宽高会变化布局要相应调整否则会溢出容器。这个细节不注意的话旋转后图片会跑偏。5.4 加载状态和错误处理网络请求总有失败的时候图片加载失败要有兜底。给 img 加 onerror 处理显示一个占位图或者重试按钮。img.onerror function() { this.src /static/error-placeholder.png; // 或者显示重试按钮 };加载中的状态也要有可以用一个骨架屏或者 loading 动画。用户看到加载提示心里有底不会以为页面卡死了。另外任务处理失败时前端要能友好提示。比如文档转换失败请检查文件格式或者服务繁忙请稍后重试。错误信息要具体别只显示一个操作失败用户根本不知道怎么办。6. 生产环境踩过的坑和稳定性保障6.1 并发转换导致的 LibreOffice 进程堆积前面提过 LibreOffice 并发问题这里展开说。我们上线初期没做并发控制用户同时上传多个文档每个请求都启动一个 LibreOffice 进程。结果服务器上瞬间几十个 soffice 进程CPU 打满内存耗尽整个服务卡死。解决办法是加信号量控制并发数。用Semaphore限制同时执行的转换任务数量超出的请求排队等待。private final Semaphore convertSemaphore new Semaphore(3); public void convert(String input, String output) { convertSemaphore.acquire(); try { // 执行转换 } finally { convertSemaphore.release(); } }并发数设多少合适取决于服务器配置。我的经验是4 核 8G 的机器LibreOffice 并发数设 2 到 3 比较稳妥。设太高反而因为资源竞争导致每个任务都变慢。6.2 临时文件清理磁盘被塞满的教训转换过程中会产生大量临时文件上传的 Word、中间 PDF、每页的图片、LibreOffice 的 profile 目录。如果这些文件不及时清理磁盘很快就会被塞满。我踩过一次坑服务跑了一周磁盘告警上去一看 /tmp 目录几十 G 的临时文件。原因是转换失败的任务没有清理临时文件日积月累就爆了。解决办法是加定时清理任务。用 Spring 的Scheduled注解每天凌晨清理超过 24 小时的临时文件。Scheduled(cron 0 0 2 * * ?) public void cleanTempFiles() { // 遍历临时目录 // 删除修改时间超过 24 小时的文件 }同时转换任务无论成功失败都要在 finally 块里清理本次任务产生的临时文件。失败的任务更要清理因为失败时文件往往被忽略了。6.3 转换超时和失败重试机制有些文档转换特别慢或者干脆卡死。必须有超时机制不能让任务无限期挂着。超时控制分两层进程级用timeout命令应用级用 Future 的 get 方法带超时。Future? future executor.submit(() - doConvert(input, output)); try { future.get(120, TimeUnit.SECONDS); } catch (TimeoutException e) { future.cancel(true); // 标记任务失败 }失败重试要谨慎。不是所有失败都值得重试比如文件本身损坏重试多少次都没用。我的做法是只对超时和临时性错误重试最多重试一次。重试前要清理上次的临时文件避免冲突。6.4 监控和日志出问题时能快速定位生产环境出问题是必然的关键是要能快速定位。我在几个关键节点都加了日志任务创建时记录任务 ID、文件名、文件大小转换开始时记录开始时间转换完成时记录耗时、输出文件大小渲染每页时记录页码、耗时任务完成或失败时记录总耗时、结果日志里带上任务 ID这样出问题时可以 grep 出整个任务的生命周期。另外关键指标要接入监控系统比如任务成功率、平均耗时、失败原因分布。这些数据能帮你提前发现潜在问题。提示日志别打太多尤其是循环里的日志。渲染 100 页每页打一条日志日志文件很快就爆了。可以每 10 页打一条或者只在失败时打详细日志。6.5 服务重启后的任务恢复服务重启时正在处理的任务会中断。如果不做处理这些任务会永远卡在处理中状态用户一直等不到结果。我的做法是服务启动时扫描任务表把状态为处理中的任务标记为失败或者重新提交。标记为失败更简单但用户体验不好。重新提交要考虑幂等性避免重复处理。更完善的做法是记录任务的检查点比如已经渲染到第几页重启后从检查点继续。但这个实现复杂度高一般场景下重新提交整个任务就够了。7. 性能优化的几个实用手段7.1 图片压缩在清晰度和体积之间找平衡前面说了 DPI 和 JPG 质量参数的权衡。除此之外还可以用工具进一步压缩图片。比如用 Thumbnailator 或者 TwelveMonkeys 这类库在保证视觉质量的前提下减小体积。另一个思路是生成两套图片一套高清用于放大查看一套低清用于列表展示。列表加载低清图用户点击查看时再加载高清图。这样首屏加载快细节也能看清。7.2 缓存策略相同文档不重复转换如果同一份文档被多次上传每次都重新转换很浪费。可以用文件内容的 MD5 作为 key缓存转换结果。下次上传相同文件时直接返回缓存的图片列表。缓存要注意失效策略。如果转换逻辑升级了旧缓存可能不适用需要清理。可以给缓存加版本号版本变了就重新转换。7.3 异步预热提前转换常用文档如果某些文档是高频访问的可以在系统空闲时提前转换好用户访问时直接返回结果。这个策略适合文档内容固定、访问量大的场景比如产品手册、规章制度这类。预热的实现很简单写个定时任务扫描需要预热的文档列表逐个转换并缓存结果。7.4 集群部署时的负载均衡单机处理能力有限文档量大时需要集群部署。集群部署要注意几个问题第一任务分发。用户上传的请求可能打到任意一台机器任务要在哪台机器执行可以用消息队列所有机器都监听队列谁抢到谁执行。或者用一致性哈希根据任务 ID 路由到固定机器。第二文件共享。前面说过中间文件用本地临时目录但最终图片要放共享存储否则用户轮询时打到别的机器就找不到图片了。第三状态同步。任务状态存在数据库里所有机器都能访问这个没问题。但要注意并发更新时的锁问题。集群部署的复杂度比单机高不少如果单机性能够用没必要上集群。等真的遇到瓶颈了再考虑。8. 一些容易被忽略的细节8.1 文件名和路径的安全处理用户上传的文件名可能包含特殊字符比如空格、中文、斜杠、甚至路径穿越字符../。如果直接拿文件名拼路径可能出安全问题。我的做法是上传后立即把文件重命名为 UUID原始文件名只存在数据库里用于展示。这样路径完全可控不会有注入风险。String originalFilename file.getOriginalFilename(); String storedFilename UUID.randomUUID().toString() .docx; // 保存时用 storedFilename数据库记录 originalFilename8.2 文件类型校验别只看扩展名只校验扩展名是不够的用户可以改扩展名绕过。要校验文件内容的魔数magic number。docx 本质是 zip 包文件头是PKdoc 是老格式文件头是D0 CF 11 E0。// 读取文件头几个字节判断类型 byte[] header new byte[8]; inputStream.read(header); // 判断是否是合法的 Office 文档更严格的做法是用 Apache Tika 这类库做类型检测它能识别几百种文件类型准确率高。8.3 大文件上传的处理默认的 SpringBoot 上传大小限制是 1MB超过就报错。要调整配置spring: servlet: multipart: max-file-size: 50MB max-request-size: 50MB但调大限制只是第一步。大文件上传本身就很慢要考虑分片上传、断点续传。如果文档普遍很大这些机制迟早要加。8.4 转换结果的持久化图片转出来后如果只存在本地磁盘服务重启或者迁移就丢了。所以最终图片一定要持久化到对象存储或者数据库。存对象存储的话数据库里只存 URL。存数据库的话一般存二进制或者 base64但大图片存数据库性能不好不推荐。我的做法是存对象存储数据库存 URL 和元信息页码、宽高、大小。这样查询快扩展也方便。8.5 前端展示的响应式适配PC 端和移动端的展示逻辑不一样。PC 端屏幕宽可以并排显示两页移动端屏幕窄一页一页显示更合适。用 CSS 媒体查询做响应式适配media (max-width: 768px) { .page-container { width: 100%; } } media (min-width: 769px) { .page-container { width: 48%; display: inline-block; } }移动端还要考虑触摸手势双指缩放、滑动翻页这些交互要处理好否则体验很别扭。9. 完整流程串一遍把整个链路串起来从用户上传到前端展示完整流程是这样的用户选择 Word 文件前端校验扩展名和大小上传到服务端服务端接收文件校验文件类型魔数保存到临时目录创建任务记录返回任务 ID异步线程从队列取出任务更新状态为转换中调用 LibreOffice 无头模式把 Word 转成 PDF输出到临时目录更新状态为渲染中用 PDFBox 逐页渲染成图片每页图片上传到对象存储记录 URL更新任务状态为成功记录图片列表清理临时文件Word、PDF、LibreOffice profile前端轮询任务状态拿到图片列表后展示用户浏览图片支持懒加载、缩放、旋转、翻页整个流程里最耗时的通常是第 4 步和第 5 步。优化也是围绕这两步做转换并发控制、渲染 DPI 调整、图片压缩、缓存复用。我在实际项目里一份 20 页的合同文档从上传到图片可访问整个过程大概 8 到 12 秒。其中 Word 转 PDF 约 3 秒PDF 转图片约 5 秒上传对象存储约 2 秒。这个速度对于后台管理系统来说可以接受用户上传后看到进度条等几秒就能看结果。如果要把速度再提上去可以考虑几个方向用更快的转换引擎商业库、降低渲染 DPI、并行渲染多页。但每个优化都有代价要根据实际需求权衡。最后分享一个我在实际使用中的小技巧转换任务的状态查询接口可以返回一个预估剩余时间。这个时间根据文档页数和当前处理进度估算前端显示预计还需 X 秒用户体验会好很多。虽然估算不一定准但比干等着强。