1. 文档处理工具链的整体设计思路1.1 为什么选择 Java 作为文档处理的主力语言在服务端做文档生成和格式转换Java 生态的成熟度是其他语言很难比的。我做过好几个类似的项目从最简单的导出 Word 报表到复杂的合同批量生成、发票 PDF 渲染、文档预览图输出Java 都有对应的工业级方案。核心原因有三个一是 Apache POI 和 iText 这类库经过十几年迭代坑基本都被踩平了二是 Java 在服务端部署的稳定性处理几百页文档时内存和线程控制更可控三是格式转换环节可以借助 LibreOffice 的命令行能力Java 只需要做进程调度和结果校验架构上很清晰。这套工具链要解决的问题其实很具体业务系统里经常需要把结构化数据渲染成 Word 或 PDF 给用户下载同时还要把已有的 docx 转成 PDF 做归档把文档转成图片做在线预览。如果每个需求都单独找方案代码会散得到处都是。所以我的思路是做一个统一的文档处理模块把生成、转换、渲染三类能力收敛到一套接口里。1.2 四类核心能力的边界划分在动手之前先把需求拆清楚很重要。我把整个工具链分成四个独立但可组合的能力生成 docx从数据模型出发用代码构建段落、表格、图片、页眉页脚输出符合 Office 规范的 Word 文件。生成 pdf直接用 PDF 库绘制内容或者先做 docx 再转换两条路线各有适用场景。docx 转 pdf保真度要求最高的环节涉及字体、分页、表格边框等细节。docx/pdf 转图片用于预览、缩略图、水印底图等场景核心是分辨率和分页控制。这四类能力不是孤立的。实际项目里最常见的链路是数据 → docx → pdf → 图片一条流水线走完。所以设计时我把每一步都做成可单独调用、也可串联的组件中间产物用临时文件或字节数组传递。1.3 技术选型的取舍逻辑选型这块我踩过不少坑这里直接说结论和理由。docx 生成用 Apache POI 的 XWPF 模块。它是目前 Java 里操作 OOXML 最完整的库支持段落、表格、图片、样式、页眉页脚。虽然 API 有点啰嗦但胜在稳定、文档全、社区问题多。备选方案是 docx4j功能也强但学习曲线更陡依赖也更重一般项目没必要。pdf 生成分两种情况。如果是从零画 PDF用 iText 或者 OpenPDFiText 的社区分支。如果是从 docx 转过来那就不需要直接生成 PDF交给转换引擎处理。这里要注意 iText 的许可证问题商业项目要评估OpenPDF 是 LGPL/MPL 双许可相对宽松。docx 转 pdf首选 LibreOffice 的无头模式命令行。原因很简单保真度最高它本身就是办公套件渲染逻辑和 Word 最接近。缺点是启动慢、依赖外部进程。备选是 docx4j 配合 fop但复杂排版容易崩表格和图片处理经常出问题我实测下来不推荐用于正式项目。转图片用 PDFBox 的 PDFRenderer把 PDF 每页渲染成 BufferedImage再输出 PNG 或 JPG。如果源文件是 docx就先转 PDF 再转图片不要试图直接从 docx 渲染图片没有靠谱的纯 Java 方案。能力首选方案备选方案关键理由生成 docxApache POI XWPFdocx4jAPI 成熟社区活跃生成 pdfOpenPDF / iTextPDFBox绘制能力强字体控制细docx 转 pdfLibreOffice 无头模式docx4j FOP保真度碾压pdf 转图片PDFBox PDFRendererIcePDF渲染质量稳定2. 核心细节解析与实操要点2.1 Apache POI 生成 docx 的关键细节用 POI 写 Word最容易出问题的地方不是 API 调用而是样式和单位的理解。POI 里很多尺寸单位是 EMUEnglish Metric Unit1 厘米等于 360000 EMU1 磅等于 12700 EMU。设置图片大小时如果单位搞错图片要么小得看不见要么撑爆页面。段落和表格的构建逻辑要清楚。XWPFDocument 是文档根对象里面可以加 XWPFParagraph 和 XWPFTable。段落里可以加 XWPFRunRun 是真正承载文字和格式的最小单位。加粗、字号、颜色、字体都设在 Run 上不是设在段落上。这个层级关系一定要理顺否则样式不生效还找不到原因。XWPFDocument doc new XWPFDocument(); XWPFParagraph p doc.createParagraph(); p.setAlignment(ParagraphAlignment.CENTER); XWPFRun run p.createRun(); run.setText(文档标题); run.setBold(true); run.setFontSize(18); run.setFontFamily(宋体);图片插入用run.addPicture()传入 InputStream、图片类型、文件名、宽度和高度EMU 单位。这里有个坑图片类型要用Document.PICTURE_TYPE_PNG这类常量不能随便传字符串。另外图片流用完要关否则文件句柄泄漏在批量生成场景下很快就把服务器拖垮。提示POI 生成 docx 时中文字体一定要显式设置否则在部分环境下会回退成默认字体导致中文显示成方框或者乱码。建议统一封装一个字体设置方法所有 Run 都走这个方法。2.2 用 OpenPDF 直接生成 PDF 的要点直接生成 PDF 适合内容结构简单、不需要复杂排版的场景比如发票、证书、简单报表。OpenPDF 的核心对象是 Document 和 PdfWriterDocument 代表逻辑页面PdfWriter 负责写字节流。字体是 PDF 生成里最麻烦的部分。默认的 Helvetica 等标准字体不支持中文必须加载中文字体文件用BaseFont.createFont()创建字体对象再设置到 Font 里。字体文件建议放在项目资源目录用类加载器读取不要用绝对路径否则换环境就找不到。BaseFont bf BaseFont.createFont(fonts/simsun.ttf, BaseFont.IDENTITY_H, BaseFont.EMBEDDED); Font titleFont new Font(bf, 18, Font.BOLD); Document doc new Document(PageSize.A4); PdfWriter.getInstance(doc, new FileOutputStream(out.pdf)); doc.open(); doc.add(new Paragraph(标题, titleFont)); doc.close();BaseFont.IDENTITY_H这个参数很关键它表示用 Unicode 编码中文才不会乱码。BaseFont.EMBEDDED表示把字体嵌入 PDF这样别人打开时即使没装这个字体也能正常显示。代价是文件会变大一个中文字体嵌入后可能增加几 MB要权衡。2.3 docx 转 pdf 的保真度控制docx 转 pdf 是整个链路里最容易翻车的环节。我用 LibreOffice 无头模式做过很多次总结下来影响保真度的因素主要有几个字体是否安装、页面设置是否一致、图片是否嵌入、表格边框样式是否标准。LibreOffice 转换时它会读取系统已安装的字体来渲染。如果服务器上没装文档里用到的字体就会用替代字体排版可能错位。所以部署时要把常用中文字体装到服务器上或者把字体文件放到 LibreOffice 的字体目录里。转换命令的核心参数是--headless、--convert-to pdf和--outdir。完整命令类似这样soffice --headless --convert-to pdf --outdir /output /input/doc.docx这里要注意LibreOffice 同时只能跑一个实例多个转换请求并发时会冲突。解决办法是给每个请求分配独立的用户配置目录用-env:UserInstallation参数指定不同的目录这样就能并行跑多个实例。soffice --headless -env:UserInstallationfile:///tmp/lo_1 \ --convert-to pdf --outdir /output /input/doc.docx注意LibreOffice 首次启动会初始化配置比较慢可能十几秒。生产环境建议常驻一个实例或者用连接池思路管理多个实例避免每次转换都冷启动。2.4 PDF 转图片的分辨率与格式选择PDF 转图片用 PDFBox 的 PDFRenderer核心是设置渲染 DPI。DPI 越高图片越清晰但文件也越大。做在线预览一般 96 到 150 DPI 够用做打印或者高清缩略图要 200 到 300 DPI。PDDocument pdf PDDocument.load(new File(in.pdf)); PDFRenderer renderer new PDFRenderer(pdf); for (int i 0; i pdf.getNumberOfPages(); i) { BufferedImage img renderer.renderImageWithDPI(i, 150); ImageIO.write(img, png, new File(page_ i .png)); } pdf.close();输出格式上PNG 无损但体积大适合文字为主的文档JPG 有损但小适合图片为主的文档。如果要做缩略图可以渲染后缩放但注意缩放算法要用高质量的否则文字会糊。我一般用Graphics2D的RenderingHints设置双线性插值。内存是转图片时的大问题。一个 A4 页面在 300 DPI 下渲染出来是 2480×3508 像素一个 BufferedImage 就占几十 MB。如果文档有几十页全部渲染完再输出会 OOM。正确做法是逐页渲染、逐页写出、及时释放不要把所有图片都留在内存里。3. 完整实操流程与核心环节实现3.1 环境准备与依赖配置先把依赖理清楚。Maven 项目里需要这几个核心依赖dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.3/version /dependency dependency groupIdcom.github.librepdf/groupId artifactIdopenpdf/artifactId version1.3.30/version /dependency dependency groupIdorg.apache.pdfbox/groupId artifactIdpdfbox/artifactId version2.0.29/version /dependencyPOI 5.x 相比 4.x 在 API 上有一些变化比如XWPFDocument的图片处理更规范了。PDFBox 2.x 和 3.x 差异较大3.x 改了不少 API如果项目里其他组件依赖 2.x就统一用 2.x别混用。LibreOffice 要单独安装不是 Java 依赖。Linux 上用包管理器装装完后确认soffice命令可用。Windows 上装完后要把安装目录加到 PATH或者用绝对路径调用。建议在配置文件里把 soffice 路径做成可配置项不同环境不一样。字体准备这块把常用的中文字体文件宋体、黑体、微软雅黑等放到服务器/usr/share/fonts/目录下然后执行fc-cache -fv刷新字体缓存。LibreOffice 和 PDFBox 都能用到这些字体。3.2 从数据到 docx 的生成实现我一般会封装一个 DocxBuilder 类把常用的段落、标题、表格、图片操作都包一层业务代码调用起来清爽。核心方法包括addTitle、addParagraph、addTable、addImage。表格生成是重头戏。POI 的表格 API 是createTable(rows, cols)然后通过getRow(i).getCell(j)拿单元格。单元格里可以再放段落设置文字和样式。表格宽度用setWidth()设置单位是 twips1 英寸 1440 twips。列宽要按比例分配否则默认平均分长文本列会挤成一团。XWPFTable table doc.createTable(3, 4); table.setWidth(100%); CTTblPr tblPr table.getCTTbl().getTblPr(); CTTblWidth width tblPr.addNewTblW(); width.setW(5000); width.setType(STTblWidth.PCT);图片插入要注意尺寸换算。假设图片原始宽度是 800 像素想让它显示为 10 厘米宽那 EMU 就是 10 × 360000 3600000。高度按原始比例算保持宽高比不变否则图片会变形。批量生成时建议把文档对象用完就关不要攒着。doc.write(outputStream)之后调doc.close()释放底层资源。如果生成几百个文档用线程池控制并发数一般 4 到 8 个线程比较合适太多反而因为 IO 竞争变慢。3.3 docx 转 pdf 的进程调度实现Java 调 LibreOffice 用ProcessBuilder。核心是把命令、参数、工作目录、超时都控制好。ListString cmd Arrays.asList( sofficePath, --headless, -env:UserInstallationfile:// profileDir, --convert-to, pdf, --outdir, outputDir, inputFile ); ProcessBuilder pb new ProcessBuilder(cmd); pb.redirectErrorStream(true); Process process pb.start(); boolean finished process.waitFor(60, TimeUnit.SECONDS); if (!finished) { process.destroyForcibly(); throw new RuntimeException(转换超时); }超时控制非常重要。LibreOffice 偶尔会卡死没有超时机制的话线程就挂在那里了。60 秒对一般文档够用大文档可以放宽到 120 秒。超时后要强制销毁进程还要清理临时配置目录。转换完成后要校验输出文件是否存在、大小是否合理。有时候 LibreOffice 返回 0 但没生成文件或者生成了 0 字节文件这些都要检测。校验通过后再把文件移到目标位置避免半成品被业务读到。并发控制上我一般用一个信号量限制同时运行的 LibreOffice 实例数比如 4 个。每个实例用独立的 UserInstallation 目录目录名带唯一标识用完删除。这样既能并行提速又不会互相干扰。3.4 pdf 转图片的批量处理实现转图片的入口方法接收 PDF 路径、输出目录、DPI、格式四个参数。内部逐页渲染每页渲染完立即写出然后调img.flush()释放。public void pdfToImages(File pdfFile, File outDir, int dpi, String format) { try (PDDocument pdf PDDocument.load(pdfFile)) { PDFRenderer renderer new PDFRenderer(pdf); int pages pdf.getNumberOfPages(); for (int i 0; i pages; i) { BufferedImage img renderer.renderImageWithDPI(i, dpi); File out new File(outDir, page_ (i 1) . format); ImageIO.write(img, format, out); img.flush(); } } }如果要做缩略图渲染完再缩放。缩放时用AffineTransform或者Graphics2D.drawImage配合RenderingHints.VALUE_INTERPOLATION_BILINEAR效果比默认的最近邻好很多。内存控制上除了逐页释放还可以设置 PDFBox 的内存使用模式。PDFRenderer有个setSubsamplingAllowed方法开启子采样能降低内存占用但会牺牲一点清晰度。对预览场景可以开对归档场景不要开。提示转图片时如果 PDF 里有透明背景PNG 能保留JPG 会变成黑色或白色。需要透明就用 PNG不需要就用 JPG 省空间。4. 常见问题与排查技巧实录4.1 中文乱码与字体缺失问题中文乱码是文档处理里出现频率最高的问题表现有好几种PDF 里中文变成方框、变成问号、变成乱码符号或者干脆不显示。排查思路按这个顺序走先确认字体文件是否存在且可读再确认代码里是否正确加载了字体最后确认字体是否嵌入。PDFBox 和 OpenPDF 加载字体时路径要对字体文件要完整。有些字体文件是 TTC 格式字体集合需要指定索引用TTFParser解析时要传对参数。LibreOffice 转换时的乱码多半是服务器没装对应字体。用fc-list | grep 字体名检查字体是否被系统识别。如果没识别把字体文件放到字体目录后刷新缓存。还有一种情况是文档里指定了某个字体但服务器上只有相似字体LibreOffice 会做字体替换替换后排版可能变样。解决办法是把原文档用到的字体都装上。4.2 转换超时与进程卡死LibreOffice 卡死的原因很多文档损坏、字体冲突、配置目录权限问题、并发冲突。排查时先看进程状态用jstack看 Java 线程卡在哪用ps看 soffice 进程是否还在。预防措施比事后排查更重要。第一每个转换请求用独立配置目录避免并发冲突。第二设置合理的超时超时后强制销毁。第三转换前校验输入文件损坏文件直接拒绝。第四定期清理临时目录避免磁盘满导致转换失败。如果发现某个文档必然导致卡死可以先用 LibreOffice 命令行手动转一次看报什么错。常见的是文档里有损坏的图片或者不规范的 XML这种文档在 Word 里能打开但 LibreOffice 解析会卡住。处理办法是用 Word 另存为新文档修复内部结构。4.3 内存溢出与资源泄漏批量处理文档时 OOM 很常见。原因通常是文档对象没关、图片流没关、BufferedImage 没释放。POI 的XWPFDocument、PDFBox 的PDDocument都实现了Closeable必须用 try-with-resources 或者手动 close。转图片场景的内存峰值最高。一个 300 DPI 的 A4 页面 BufferedImage 约 35 MB如果同时渲染 10 页就是 350 MB。解决办法是逐页处理渲染一页写一页释放一页。如果业务要求一次性返回所有图片那就把图片写到磁盘再读不要全放内存。JVM 参数也要调。处理大文档时堆内存给到 2G 到 4G用-Xmx设置。但不要无限加大内存越大 GC 停顿越长。更好的做法是控制并发数用队列削峰。问题现象可能原因排查方法解决措施中文显示方框字体未加载或未嵌入检查字体文件与加载代码加载中文字体并设置 EMBEDDED转换超时文档损坏或并发冲突手动命令行转换看报错独立配置目录 超时销毁内存溢出对象未关闭或并发过高看堆 dump 找大对象try-with-resources 限流图片模糊DPI 过低或缩放算法差检查渲染 DPI 设置提高 DPI 双线性插值表格错位列宽单位或比例错误检查 twips 换算按比例设置列宽4.4 转换结果校验与异常兜底生产环境不能假设转换一定成功。每次转换后都要校验文件是否存在、大小是否大于阈值、PDF 是否能正常打开、页数是否和源文档一致。校验不通过就抛异常让上层决定重试还是降级。降级策略可以这样设计docx 转 pdf 失败时尝试用备选引擎再转一次如果还失败返回源 docx 让用户自己下载。转图片失败时返回一个占位图不要让整个预览功能挂掉。日志要打全。输入文件路径、输出文件路径、耗时、退出码、错误输出都要记录。出问题时这些日志就是排查依据。我习惯在转换前后各打一条日志中间的错误输出单独收集方便定位。4.5 性能优化与批量处理技巧单文档转换优化空间有限批量处理才是性能关键。几个实用技巧第一复用 LibreOffice 实例。冷启动十几秒热启动一两秒差距很大。可以用一个常驻进程池每个进程处理完一个任务后不退出继续接下一个。第二并行度要匹配 CPU 核数。LibreOffice 转换是 CPU 密集型并行度设成核数的 1 到 1.5 倍比较合适。太多会互相抢 CPU反而变慢。第三临时文件用内存盘。如果服务器有/dev/shm把临时目录设到那里IO 速度快很多。但要注意内存盘容量有限大文件不要放。第四批量转图片时如果只需要缩略图先渲染低 DPI 再放大比直接渲染高 DPI 快很多。清晰度要求不高的场景可以用这招。第五缓存转换结果。同一个文档多次转换的场景用文件哈希做 key 缓存结果避免重复劳动。缓存要设过期时间避免磁盘无限增长。这套工具链我在几个项目里都用过最深的体会是生成环节可控性最强转换环节最依赖外部环境图片环节最吃内存。把这三块的边界划清楚每个环节做好校验和兜底整体稳定性就有保障。字体和并发是两个永恒的主题部署时多花十分钟检查字体能省掉后面几小时的排查。
阅读完成 · 觉得有帮助?