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 或者 OpenPDF(iText 的社区分支)。如果是从 docx 转过来,那就不需要直接生成 PDF,交给转换引擎处理。这里要注意 iText 的许可证问题,商业项目要评估,OpenPDF 是 LGPL/MPL 双许可,相对宽松。
docx 转 pdf首选 LibreOffice 的无头模式命令行。原因很简单:保真度最高,它本身就是办公套件,渲染逻辑和 Word 最接近。缺点是启动慢、依赖外部进程。备选是 docx4j 配合 fop,但复杂排版容易崩,表格和图片处理经常出问题,我实测下来不推荐用于正式项目。
转图片用 PDFBox 的 PDFRenderer,把 PDF 每页渲染成 BufferedImage,再输出 PNG 或 JPG。如果源文件是 docx,就先转 PDF 再转图片,不要试图直接从 docx 渲染图片,没有靠谱的纯 Java 方案。
| 能力 | 首选方案 | 备选方案 | 关键理由 |
|---|---|---|---|
| 生成 docx | Apache POI XWPF | docx4j | API 成熟,社区活跃 |
| 生成 pdf | OpenPDF / iText | PDFBox | 绘制能力强,字体控制细 |
| docx 转 pdf | LibreOffice 无头模式 | docx4j + FOP | 保真度碾压 |
| pdf 转图片 | PDFBox PDFRenderer | IcePDF | 渲染质量稳定 |
2. 核心细节解析与实操要点
2.1 Apache POI 生成 docx 的关键细节
用 POI 写 Word,最容易出问题的地方不是 API 调用,而是样式和单位的理解。POI 里很多尺寸单位是 EMU(English Metric Unit),1 厘米等于 360000 EMU,1 磅等于 12700 EMU。设置图片大小时如果单位搞错,图片要么小得看不见,要么撑爆页面。
段落和表格的构建逻辑要清楚。XWPFDocument 是文档根对象,里面可以加 XWPFParagraph 和 XWPFTable。段落里可以加 XWPFRun,Run 是真正承载文字和格式的最小单位。加粗、字号、颜色、字体都设在 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 和 PdfWriter,Document 代表逻辑页面,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:UserInstallation=file:///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> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.3</version> </dependency> <dependency> <groupId>com.github.librepdf</groupId> <artifactId>openpdf</artifactId> <version>1.3.30</version> </dependency> <dependency> <groupId>org.apache.pdfbox</groupId> <artifactId>pdfbox</artifactId> <version>2.0.29</version> </dependency>POI 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()设置,单位是 twips(1 英寸 = 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。核心是把命令、参数、工作目录、超时都控制好。
List<String> cmd = Arrays.asList( sofficePath, "--headless", "-env:UserInstallation=file://" + 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 缓存结果,避免重复劳动。缓存要设过期时间,避免磁盘无限增长。
这套工具链我在几个项目里都用过,最深的体会是:生成环节可控性最强,转换环节最依赖外部环境,图片环节最吃内存。把这三块的边界划清楚,每个环节做好校验和兜底,整体稳定性就有保障。字体和并发是两个永恒的主题,部署时多花十分钟检查字体,能省掉后面几小时的排查。