1. 办公文档转PDF与签章的整体设计思路做过企业级文档中台的朋友大概率都碰过这个需求业务系统里沉淀了大量 Word 合同、PPT 汇报材料前端要统一在线预览还要在归档环节给 PDF 盖上电子签章。这个需求听起来一句话真动手做的时候会发现坑一个接一个——Word 转出来的 PDF 字体乱码、PPT 转出来排版错位、签章盖上去位置飘了、多页文档签章只盖了第一页。我前后在几个项目里反复打磨过这套链路今天把完整思路和踩坑记录摊开讲。先说清楚这套方案到底解决什么问题。核心目标有三个第一把Word.doc/.docx和 PPT.ppt/.pptx统一转成 PDF保证版式尽量不失真第二对生成的 PDF加盖签章包括企业公章、个人签名章或者骑缝章第三整条链路要能在 Java 服务端稳定跑支持批量、支持并发、支持异常兜底。适合谁看正在做 OA、合同管理、档案归档、在线教育课件预览这类系统的后端开发尤其是被文档转换折磨过一轮、想找一套能直接抄作业方案的人。技术选型上标题已经点明了两个核心库Apache POI和iTextPDF。这里要先纠正一个常见误解——很多人以为 POI 能直接把 Word 转 PDF其实 POI 本身只负责解析和读取 Office 文档结构它不负责渲染成 PDF。真正干转换这活的是另外的组件。所以整套方案实际上是POI 负责读、转换引擎负责渲染、iTextPDF 负责签章的三段式结构。为什么这么设计因为 POI 对 docx、pptx 的 XML 结构解析最成熟能拿到段落、表格、图片、文本框这些元素而渲染环节交给专门的转换器能最大程度保留原始版式最后 iTextPDF 在 PDF 操作层面是 Java 生态里最稳的库之一加签章、加水印、合并拆分都很成熟。我见过不少团队一上来就想用纯 POI iText 手撸渲染把 Word 的每个段落、每个 run 手动画到 PDF 上。这条路我劝你别走除非你的文档格式极其单一。原因很简单Word 的排版模型和 PDF 的排版模型根本不是一回事行距、段前段后、制表位、浮动图片、文本框环绕这些你一个个去映射工作量巨大且永远覆盖不全。所以务实的做法是能用成熟转换器就用转换器POI 只在需要精细控制内容时介入。下面这张表是我总结的几种转换路线对比选型时可以直接参考转换路线适用场景版式保真度部署复杂度备注POI 转换器组件通用 Word/PPT 转 PDF高中主流方案推荐纯 POI iText 手绘格式极简、需完全可控低低不推荐复杂文档调用外部转换服务已有独立转换能力高低依赖外部稳定性前端 JS 转换轻量预览中低不适合服务端归档选型背后的逻辑其实就一句话把专业的事交给专业的组件。POI 的强项是解析转换器的强项是渲染iText 的强项是 PDF 后处理各司其职链路才稳。理解了这一点后面的实操就不会走偏。2. 环境准备与依赖选型的关键细节动手之前环境这块必须先理清楚否则后面报错能让你怀疑人生。我按 Maven 项目来说Gradle 的同学自行换算。2.1 核心依赖清单与版本坑POI 的依赖不是引入一个poi就完事Word 和 PPT 需要不同的模块。docx 用poi-ooxmldoc 用poi-scratchpadpptx 同样依赖poi-ooxmlppt 老格式也要poi-scratchpad。iTextPDF 这边要注意iText 5 和 iText 7 的 API 差异巨大网上大量老教程是 iText 5 的写法直接抄到 iText 7 上会编译不过。我个人建议新项目直接上iText 7模块化更清晰签章相关的kernel、layout、sign分包管理。dependencies !-- POI 核心处理 docx/pptx -- dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.3/version /dependency !-- POI 处理老格式 doc/ppt -- dependency groupIdorg.apache.poi/groupId artifactIdpoi-scratchpad/artifactId version5.2.3/version /dependency !-- iText7 核心 -- dependency groupIdcom.itextpdf/groupId artifactIdkernel/artifactId version7.2.5/version /dependency dependency groupIdcom.itextpdf/groupId artifactIdlayout/artifactId version7.2.5/version /dependency dependency groupIdcom.itextpdf/groupId artifactIdsign/artifactId version7.2.5/version /dependency /dependencies注意POI 5.x 相比 4.x 在 API 上有不少调整尤其是XWPFDocument相关的一些方法签名。如果你是从老项目迁移先跑一遍编译别等到运行时才发现方法没了。2.2 字体与中文字体嵌入问题这是中文文档转换最容易翻车的地方。Word 里用的宋体、黑体、微软雅黑转换器所在的环境如果没有这些字体转出来的 PDF 要么是方块要么是乱码。解决方案有两个方向一是在服务器上安装对应字体Linux 下把 ttf 文件丢到/usr/share/fonts/然后fc-cache -fv刷新二是在转换时显式指定字体映射把文档里的字体名映射到系统实际存在的字体。我踩过最深的坑是测试环境是 Windows字体齐全转出来完美一上生产 Linux 服务器所有中文全变方块。排查了半天才反应过来是字体缺失。所以部署清单里一定要把字体安装单独列一项别指望运维会主动帮你装。2.3 临时文件与内存管理文档转换是典型的 IO 密集 内存敏感操作。大文档转换时转换器往往会在临时目录生成中间文件。如果临时目录空间不足或者转换完没清理跑一段时间磁盘就爆了。我的做法是为每次转换创建独立的临时目录转换完成后在finally块里递归删除。同时给 JVM 设置合理的堆大小大文档转换时内存峰值可能到几百 MB。Path tempDir Files.createTempDirectory(doc-convert-); try { // 转换逻辑中间文件都放 tempDir } finally { // 递归删除 tempDir deleteRecursively(tempDir); }提示临时目录的清理一定要放在finally否则一旦转换抛异常临时文件就永远留在那了。我见过一个系统跑了半年临时目录堆了几十 G 的垃圾文件。3. Word 与 PPT 转 PDF 的实操实现环境就绪后进入核心转换环节。这里我把 Word 和 PPT 分开讲因为两者的处理细节差别不小。3.1 Word 转 PDF 的完整流程Word 转 PDF 的链路是用 POI 打开文档 → 交给转换器渲染 → 输出 PDF 流。这里的关键在于转换器的选择。市面上常见的方案有基于 LibreOffice 的转换、基于商业组件的转换等。我以最常见的调用本地转换能力思路来展开因为这是可控性最好的方式。先看用 POI 读取 Word 内容的基本操作这一步在需要做内容预处理比如替换占位符、提取文本做校验时必不可少public String readWordText(InputStream inputStream) throws IOException { StringBuilder sb new StringBuilder(); try (XWPFDocument doc new XWPFDocument(inputStream)) { // 读取段落 for (XWPFParagraph paragraph : doc.getParagraphs()) { sb.append(paragraph.getText()).append(\n); } // 读取表格 for (XWPFTable table : doc.getTables()) { for (XWPFTableRow row : table.getRows()) { for (XWPFTableCell cell : row.getTableCells()) { sb.append(cell.getText()).append(\t); } sb.append(\n); } } } return sb.toString(); }读取只是辅助真正的转换我推荐封装成一个统一的服务方法输入是文档流和格式类型输出是 PDF 字节数组。这样做的好处是调用方不需要关心底层用的是哪个转换器将来换实现也不影响业务代码。public byte[] convertToPdf(InputStream source, DocType type) throws Exception { Path tempDir Files.createTempDirectory(convert-); try { Path sourceFile tempDir.resolve(source. type.getExt()); Files.copy(source, sourceFile, StandardCopyOption.REPLACE_EXISTING); Path targetFile tempDir.resolve(target.pdf); // 调用转换引擎把 sourceFile 转成 targetFile doConvert(sourceFile, targetFile, type); return Files.readAllBytes(targetFile); } finally { deleteRecursively(tempDir); } }这里有个细节值得说为什么先落盘再转换而不是直接流式转换因为大多数转换引擎对文件路径的支持比对流的支持更稳定尤其是涉及图片、嵌入对象时流式转换容易丢资源。落盘虽然多一次 IO但换来的是转换成功率的提升这笔账划算。3.2 PPT 转 PDF 的特殊处理PPT 转 PDF 比 Word 更麻烦因为 PPT 是绝对定位的每个元素都有精确的坐标而且幻灯片尺寸、母版、主题都会影响最终效果。用 POI 读取 PPT 时XMLSlideShow是核心类public void readPptInfo(InputStream inputStream) throws IOException { try (XMLSlideShow ppt new XMLSlideShow(inputStream)) { // 幻灯片尺寸转 PDF 时页面大小要跟它对齐 Dimension pageSize ppt.getPageSize(); System.out.println(页面尺寸: pageSize.getWidth() x pageSize.getHeight()); // 遍历每张幻灯片 for (XSLFSlide slide : ppt.getSlides()) { for (XSLFShape shape : slide.getShapes()) { // 处理文本、图片、表格等 } } } }PPT 转 PDF 最容易出问题的地方是页面尺寸不匹配。PPT 默认是 4:3 或 16:9如果转换时 PDF 页面用了 A4 尺寸内容就会被拉伸或留白。所以转换前一定要拿到 PPT 的实际页面尺寸让 PDF 页面与之保持一致。另外PPT 里的动画、切换效果在 PDF 里是无法保留的这是格式本身的限制要提前跟业务方说清楚别等交付了才被质疑为什么动画没了。3.3 转换质量的关键控制点转换质量这块我总结了几个必须盯住的点做成表格方便对照控制点常见问题处理方式字体中文乱码、方块服务器装字体 字体映射图片丢失、模糊检查嵌入资源路径避免流式转换表格边框错位、跨页断裂转换器版本要新老版本 bug 多页眉页脚丢失确认转换器支持必要时手动补分页与原文不一致调整页面尺寸和边距参数实操心得转换器的版本一定要用较新的稳定版。我遇到过老版本转换器把带合并单元格的表格转得面目全非升级版本后直接就好了。文档转换这领域版本迭代带来的质量提升非常明显。4. PDF 加盖签章的核心实现PDF 生成之后签章环节才是真正体现业务价值的地方。签章分两种一种是图片签章把公章图片贴到 PDF 指定位置一种是数字签名用证书做密码学签名防篡改。标题里说的加盖签章通常指前者但实际项目里两者经常一起用。我重点讲图片签章因为这是最高频的需求。4.1 图片签章的位置计算签章最难的不是盖上去而是盖对位置。PDF 的坐标系原点在左下角而我们在页面上看到的视觉位置是从左上角算的这两个坐标系一转换很多人就晕了。iText 7 里页面高度用page.getPageSize().getHeight()拿如果你想让签章距离页面顶部 100 像素、距离左边 200 像素那么签章的 y 坐标应该是页面高度 - 100 - 签章高度。public void stampImage(byte[] pdfBytes, byte[] sealBytes, float xFromLeft, float yFromTop, float width, float height) throws IOException { try (PdfDocument pdfDoc new PdfDocument( new PdfReader(new ByteArrayInputStream(pdfBytes)), new PdfWriter(new ByteArrayOutputStream()))) { // 遍历每一页盖章 for (int i 1; i pdfDoc.getNumberOfPages(); i) { PdfPage page pdfDoc.getPage(i); float pageHeight page.getPageSize().getHeight(); // 坐标系转换视觉左上角 - PDF 左下角 float pdfY pageHeight - yFromTop - height; ImageData imageData ImageDataFactory.create(sealBytes); Image image new Image(imageData) .setFixedPosition(i, xFromLeft, pdfY) .scaleToFit(width, height); new Canvas(new PdfCanvas(page), page.getPageSize()) .add(image); } } }这段代码里setFixedPosition的第二个参数是 x第三个是 y都是相对于页面左下角的。坐标系转换是签章位置飘忽的罪魁祸首我建议你写个工具方法专门做这个转换别在业务代码里到处手算。4.2 骑缝章与多页签章合同场景经常要求盖骑缝章——章要跨在多页的边缘每页只显示一部分。这个实现起来稍微绕一点你需要把完整的章图片按页数切分每页只画对应的一条。假设合同 5 页章宽 200那么每页画 40 宽度的切片位置在页面右边缘。public void stampRidingSeal(byte[] pdfBytes, byte[] sealBytes, int totalPages) throws IOException { // 思路把章图按页数横向切分每页画一条 // 第 i 页画的是从 (i-1)*sliceWidth 到 i*sliceWidth 的部分 // 具体裁剪用 iText 的裁剪区域实现 }骑缝章的核心是切片计算和裁剪区域iText 7 里可以用PdfCanvas配合裁剪路径实现。这块代码量稍大但逻辑清晰关键是切片宽度要按页数均分且每页的垂直位置要一致否则章看起来是断的。4.3 数字签名与防篡改如果业务要求签章不可篡改那就得上数字签名。iText 7 的sign模块提供了完整的签名能力核心是用证书对 PDF 做密码学签名。签名之后PDF 任何一处被改动签名校验都会失败。public void signPdf(byte[] pdfBytes, PrivateKey privateKey, Certificate[] chain) throws Exception { ByteArrayOutputStream output new ByteArrayOutputStream(); PdfReader reader new PdfReader(new ByteArrayInputStream(pdfBytes)); PdfSigner signer new PdfSigner(reader, output, new StampingProperties().useAppendMode()); // 设置签名外观、位置等 signer.setFieldName(signature); // 执行签名 signer.signDetached(new BouncyCastleDigest(), new PrivateKeySignature(privateKey, SHA256), chain, null, null, null, 0, PdfSigner.CryptoStandard.CMS); }注意数字签名和图片签章是两回事。图片签章只是看起来盖了章图片本身可以被替换数字签名是密码学层面的保护能证明文档未被篡改。合规要求高的场景两者要结合使用——先盖图片章再做数字签名把整个文档含章锁死。5. 常见问题与排查技巧实录这套链路我跑了很久积累了一批典型问题和排查方法整理成速查表遇到问题直接对号入座。问题现象可能原因排查方向解决方案中文变方块服务器缺字体检查/usr/share/fonts安装字体并刷新缓存转换后排版错乱转换器版本旧查看转换器日志升级到稳定新版签章位置偏移坐标系搞反打印页面高度核对用统一转换工具方法大文档 OOM内存不足看 JVM 堆和 GC 日志调大堆 分批处理临时文件堆积未清理检查临时目录finally 中递归删除并发转换失败转换器非线程安全压测复现加锁或每线程独立实例图片丢失流式转换检查资源加载改为落盘转换5.1 并发场景下的线程安全问题这是生产环境最容易翻车的地方。很多转换器组件不是线程安全的单线程测试一切正常一上并发就各种诡异错误。我的处理方式是每个转换任务使用独立的转换器实例或者用一个对象池管理。千万别图省事搞个全局单例那是在给自己埋雷。// 反例全局单例并发下会出问题 private static final Converter INSTANCE new Converter(); // 正例每次转换创建独立实例或用 ThreadLocal public byte[] convert(InputStream in) { Converter converter new Converter(); try { return converter.doConvert(in); } finally { converter.close(); } }5.2 大文档转换的超时与内存控制几百页的 Word 或者上百张幻灯片的 PPT转换时间可能到几十秒甚至几分钟。这种场景下同步接口很容易超时。我的建议是把转换做成异步任务接口收到请求后立即返回任务 ID后台线程池慢慢转前端轮询任务状态。这样既避免了 HTTP 超时也能控制并发转换的数量防止内存被打爆。线程池的大小要结合服务器配置来定。转换任务是 CPU 和内存双密集的线程数不宜过多一般设为CPU 核心数 1到CPU 核心数 * 2之间比较稳妥。设太大反而会因为内存竞争导致整体变慢。5.3 签章图片的透明背景处理公章图片通常是 PNG 带透明背景的但有些业务方给的图是白底 JPG。白底章盖上去会挡住下面的文字非常难看。处理办法是在代码里做白底转透明或者要求业务方提供透明 PNG。如果只能拿到白底图可以用 Java 的图像处理把接近白色的像素设为透明public BufferedImage makeWhiteTransparent(BufferedImage src) { int w src.getWidth(), h src.getHeight(); BufferedImage dest new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); for (int x 0; x w; x) { for (int y 0; y h; y) { int rgb src.getRGB(x, y); int r (rgb 16) 0xFF; int g (rgb 8) 0xFF; int b rgb 0xFF; // 接近白色的像素设为透明 if (r 240 g 240 b 240) { dest.setRGB(x, y, 0x00FFFFFF); } else { dest.setRGB(x, y, rgb); } } } return dest; }提示这个阈值 240 不是固定的要看实际图片的白底程度。有些扫描件白底偏灰阈值要调低。建议做成可配置参数不同来源的章图用不同阈值。6. 性能优化与工程化落地建议功能跑通只是第一步真正上生产还得考虑性能和工程化。这块我分享几个实战中验证有效的优化点。6.1 转换结果的缓存策略同一份文档如果被反复转换纯属浪费资源。我的做法是对源文档内容做哈希以哈希值为 key 缓存转换结果。文档内容没变直接返回缓存的 PDF内容变了哈希值变化重新转换。缓存可以放本地Caffeine也可以放分布式缓存Redis看部署规模。public byte[] convertWithCache(byte[] source, DocType type) { String hash DigestUtils.md5Hex(source) - type.name(); byte[] cached cache.getIfPresent(hash); if (cached ! null) { return cached; } byte[] pdf doConvert(source, type); cache.put(hash, pdf); return pdf; }缓存要注意过期策略和容量上限。PDF 文件通常不小缓存太多会吃内存建议设置最大条目数和过期时间比如最多缓存 500 份、2 小时过期。6.2 签章模板化配置签章位置如果写死在代码里业务一调整就得改代码重新发版非常不灵活。我的做法是把签章配置抽成模板用 JSON 描述章图路径、位置相对页面哪个角、偏移量、尺寸、盖哪些页。业务方改配置就行不用动代码。{ sealType: company, imagePath: /seals/company.png, anchor: BOTTOM_RIGHT, offsetX: -150, offsetY: 100, width: 120, height: 120, pages: LAST }anchor表示锚点offsetX/offsetY是相对锚点的偏移pages支持ALL、LAST、FIRST或者具体页码列表。这样一套配置能覆盖绝大多数签章场景扩展性也好。6.3 监控与日志埋点文档转换和签章是容易出问题的环节必须有足够的日志和监控。我一般会记录这几个指标转换耗时、转换成功率、签章成功率、临时目录占用。转换失败时把源文档和异常堆栈一起记下来方便事后复现。别小看这些埋点出问题时能帮你省下大量排查时间。实操心得转换失败的源文档一定要保留一份到专门的失败目录但要注意脱敏和存储周期。有些文档含敏感信息不能长期留存建议保留 7 天自动清理。这样既能复现问题又不至于有合规风险。7. 我在实际项目中的几点体会这套链路我从最初的手忙脚乱到后来的驾轻就熟中间踩的坑基本都写在上面的章节里了。最后再补几个零散但很实用的体会。第一别迷信一次转换完美。文档格式千奇百怪总有意料之外的排版。与其追求 100% 保真不如建立一套转换后校验机制——转换完检查页数是否合理、关键文字是否存在不通过就告警人工介入。这比盲目相信转换器靠谱得多。第二签章的视觉位置一定要让业务方确认。我吃过亏代码里按距顶部 100 像素盖的章业务方看了说太靠上了要往下挪。这种主观的东西最好在测试阶段就截图给业务方确认别等上线了再返工。第三版本升级要谨慎。POI 和 iText 的版本升级有时会带来行为变化尤其是转换质量相关的。升级前一定要用一批真实文档做回归测试对比升级前后的转换结果确认没有退化再上生产。第四给转换和签章留足超时时间。大文档转换慢是正常的别把超时设得太短否则正常的大文档也会被判失败。我一般把单文档转换超时设在 5 分钟签章设在 1 分钟具体看文档规模调整。这套方案我在几个项目里都跑通了Word、PPT 转 PDF 的保真度能满足绝大多数业务场景签章的位置控制和防篡改能力也经得起考验。如果你正在做类似的需求希望这些内容能帮你少走点弯路。真遇到上面没覆盖的怪问题大概率是字体或者版本兼容的锅顺着这两个方向查八九不离十。