
去年做内部文档管理平台时接了一个需求把积压的几千份PDF审批单、标书、技术手册批量转成可编辑的Word文档。当时我的第一反应是“找个Java库调一下不就行了”结果真正动手才发现PDF转Word这件事远不是“读出来再写进去”那么简单。这篇记录就把我在Java生态里做PDF转Word的完整过程写出来包括方案选型、代码实现、布局保真处理和一堆实测踩坑记录。如果你正在为PDF内容复制粘贴痛苦、或者要搭一个文档转换服务这些内容应该能直接帮你省下不少时间。先说结论在Java里做PDF转Word没有银弹。开源库方案需要自己拼装版面重建逻辑商业库方案省心但要花钱扫描件还得引入OCR。关键在于先想清楚你的文档是什么类型、要转换到什么质量级别再选技术路线。下面展开讲。1. PDF转Word为什么难先搞清楚PDF到底怎么存的很多人以为PDF转Word就像txt转Word一样把字符挪过去再换个格式就行了。实际上PDF和Word的底层设计哲学完全相反不理解这一点后面所有的坑你都会踩一遍。1.1 PDF的“绘制”逻辑决定了转换的本质PDF本质上是一套“页面描述语言”它的目标是让任何设备、任何系统上看起来都一样。所以我打开PDF文件看到的不是“标题”“段落”“表格”这种语义结构而是一连串的绘制指令在哪个坐标、用哪个字体、以多大字号、把哪个字形画出来。打个比方Word文档像一份写好的HTML有标签、有层级、有样式PDF更像一张已经渲染好的网页截图机器只能看到像素位置看不出哪个是h1、哪个是表格单元格。PDF内部的内容流大概是这样的用BT开始一个文本块用Tf设置字体字号用Td指定画字位置的坐标再用Tj把字符内容画出来。也就是说PDF只告诉我“在(100, 700)这个点画了个‘合’字”不会告诉我“这里是一个合同的标题”。所以PDF转Word的本质是一个逆向的版面重构过程把所有字符的坐标、字体、字号、颜色信息捞出来再根据这些视觉特征反推段落、标题、列表、表格最后用Word的API重新组织出来。这就像拿着乐高成品图反推说明书一样能还原到什么程度完全看算法和运气。1.2 先体检再动手三种PDF类型与检测方法决定技术路线之前第一件事是给文档“体检”判断它属于哪种类型。按我的经验PDF可以分三类文本型PDF带文本层字符可以直接提取。比如用Word/WPS另存为PDF、Adobe Acrobat生成的PDF大部分属于这类。这类文档用PDFBox可以直接走文本提取路线。扫描型PDF整页是一张图片没有文本层本质上就是图像。比如用扫描仪扫出来的合同、书籍扫描件。这类不走OCR完全没法提取文字。混合型PDF一部分页面有文本层一部分是扫描图或者一个页面里既有文字也有大量嵌入图片。这类最麻烦批量处理时最容易翻车。判断方法其实很简单用PDFBox试提一下文本就行try (PDDocument document PDDocument.load(new File(input.pdf))) { PDFTextStripper stripper new PDFTextStripper(); String text stripper.getText(document); // 如果text长度接近0大概率是扫描件需要走OCR System.out.println(text.length() 0 ? 含文本层 : 疑似扫描件); }别小看这一步。我接过一个“混合文档”前20页是扫描的审批单图片后30页是正常的文本PDF。如果整篇一刀切走文本提取前半部分就全丢了如果一刀切走OCR后半部分又平白无故慢好几倍。正确的做法是按页探测解析每一页的内容流统计字符数量和图片数量文本比例低于阈值就自动把这一页丢给OCR流水线。批量工具能不能扛住真实数据就看这套分流逻辑做没做扎实。2. Java生态方案选型开源库、商业库到底怎么挑在工单里搜“java pdf to word”答案五花八门但很多是七八年前的老帖子甚至有人推荐iText来转Word。实际上iText对PDF的生成和操作确实是事实标准但它并不擅长PDF转Word。选型之前先把各家工具的定位弄清楚。2.1 主流方案横向对比我整理了一张表把Java里能做这件事的库按开源、商业、授权和侧重点过了一遍方案开源/商业转换保真度上手成本适用场景Apache PDFBox开源Apache-2.0文本/图像提取强版面重建需自研中自建转换管线、按页批处理Apache POI开源Apache-2.0不解析PDF专精Word/Excel生成低与PDF解析库配合生成docxiTextAGPL/商业PDF生成强转Word弱高更多用于PDF水印、签名、生成OpenPDF开源LGPL/MPL偏向PDF读写转Word同样弱高PDF合并、拆分等基础操作Aspose.PDF商业高几乎一行saveToWord低企业预算充足、追求保真Spire.PDF商业免费版有限制较高输出排版较完整低轻量商业方案、小批量试用tabula-java开源MIT专注表格提取结构化输出强中从PDF里提取表格数据这里有个关键认知Java里真正“从PDF解析到Word生成”的完整闭环开源方案基本没有现成的、开箱即用的库。PDFBox负责解析PDFPOI负责写Word中间的一大段“版面重建逻辑”需要你自己写。商业库则把整个闭环封装好了代价是授权费。2.2 我的选型建议开源管线加商业兜底项目初期如果预算有限我的建议是“开源管线为主、商业库兜底”的混合策略批量大、格式相对规整的文档走PDFBox POI tabula自建管线成本为零质量可以达到“内容可复用”的级别。复杂排版、表格密集、需要“看起来就是原版”的文档用Aspose或Spire这类商业库单独跑一遍毕竟这类文档占比通常不到三成按量购买授权更划算。扫描文档无论用哪个库都救不了必须接OCR引擎比如Tesseract或PaddleOCR。敏感内部文件千万别扔到公共在线转换网站数据一旦上传就很难说清楚流向这属于数据合规问题。另外提一句授权问题Apache-2.0是最宽松的修改代码不强制开源AGPL协议对商用服务很苛刻只要通过网络对外提供功能就要求把整个服务端源码开源商业库更要看好授权范围和价格。选型时这些比技术指标更重要毕竟没人想上线后收到法务通知。3. 动手实现PDFBox POI 自建转换管线这一节是最核心的部分我从依赖引入开始按“初级→进阶→专项”的顺序把自建管线的完整思路讲一遍。代码都是真实可跑的片段但为了篇幅做了简化核心逻辑没删。3.1 依赖和版本先避开两个老坑先看pom里要引入什么dependency groupIdorg.apache.pdfbox/groupId artifactIdpdfbox/artifactId version2.0.31/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.5/version /dependency !-- 表格提取按需引入 -- dependency groupIdtechnology.tabula/groupId artifactIdtabula/artifactId version1.0.5/version /dependency两个老坑提醒你提前避开。第一PDFBox 3.x已经把PDDocument.load(File)换成了Loader.loadPDF(File)。网上大量教程还停留在2.x写法如果你用的是3.x直接抄旧代码会编译不过。我的项目用的是2.x因为资料多、API稳定你新建项目直接用3.x也没问题只需把入口方法换掉。第二POI版本别用太老的。4.x之前对docx的样式支持不完善写中文字体、图片、表格布局都有各种问题。5.x之后整体稳定很多建议新项目起步就是5.x。3.2 基础版先让“文本流”动起来最简单的版本就是“提取文本塞进Word”。这版做出来很快但只适合应急提取文字内容try (PDDocument pdf PDDocument.load(new File(INPUT_PDF)); XWPFDocument word new XWPFDocument(); FileOutputStream out new FileOutputStream(OUTPUT_DOCX)) { PDFTextStripper stripper new PDFTextStripper(); String text stripper.getText(pdf); for (String line : text.split(\\r?\\n)) { XWPFParagraph para word.createParagraph(); XWPFRun run para.createRun(); run.setText(line); } word.write(out); }这段代码跑起来你会发现几个问题原PDF的标题、正文字号全丢了列出的内容变成平平无奇的正文多栏排版的PDF段落顺序还会错乱因为PDFTextStripper默认按内容流顺序输出不是按视觉阅读顺序表格内容倒是能提取但变成一行行碎片没有任何表格结构。所以基础版只能叫“文本抢救”离交付还差得远。继续往下走。3.3 进阶版用坐标重建段落和标题真正能用的转换关键在于拿到每个字符的坐标再根据坐标把版面重排出来。PDFTextStripper留了口子可以覆写它的processTextPosition方法获取字符级信息public class LayoutStripper extends PDFTextStripper { private final ListTextLine lines new ArrayList(); private final ListTextPosition currentLine new ArrayList(); private float lastY -1; public LayoutStripper() throws IOException { super(); } Override protected void processTextPosition(TextPosition text) { float y text.getYDirAdj(); if (lastY -1 || Math.abs(y - lastY) 3) { flushLine(); } lastY y; currentLine.add(text); } private void flushLine() { if (currentLine.isEmpty()) return; currentLine.sort(Comparator.comparing(TextPosition::getXDirAdj)); StringBuilder builder new StringBuilder(); float maxSize 0; for (TextPosition tp : currentLine) { builder.append(tp.getUnicode()); maxSize Math.max(maxSize, tp.getFontSizeInPt()); } lines.add(new TextLine(builder.toString(), maxSize, lastY)); currentLine.clear(); } }这段代码的思路跟人眼读文章一样把同一水平位置、y坐标接近的字符归为一行行内按x坐标从左到右排好遇到y坐标变化较大的字符说明换行了。拿到行之后再根据行距和字号判断段落边界——行间距明显大于正常行距的地方就是段落断开的位置。写回Word时可以趁机把标题识别做了字号大于正文1.5倍、且居中或靠左无缩进的行大概率是标题。可以根据字号映射到Word的多级标题样式。虽然这个方法略粗暴但对大多数技术文档和标书准确率已经能到八成以上。3.4 图片提取与回填注意单位换算图片是PDF转Word最常见的翻车点。PDF里的图片是嵌在页面内容流里的XObject需要遍历页面资源才能抠出来PDResources resources page.getResources(); for (COSName name : resources.getXObjectNames()) { PDXObject xobject resources.getXObject(name); if (xobject instanceof PDImageXObject) { PDImageXObject image (PDImageXObject) xobject; // 如果原始图片就是JPEG别用getImage()转成BufferedImage重新压 // 直接原样拷贝字节流体积和质量都最理想 if (jpg.equalsIgnoreCase(image.getSuffix())) { try (InputStream is image.createInputStream()) { Files.copy(is, Paths.get(asset_ pageIndex _ count .jpg)); } } else { BufferedImage bimg image.getImage(); ImageIO.write(bimg, png, new File(asset_ pageIndex _ count .png)); } } }把图片插回Word时有个大坑POI的addPicture方法要求宽高单位是EMU而不是像素或pt。很多人在这里踩坑图片插进去要么巨大要么看不见。换算关系很简单1pt等于12700 EMU1英寸等于72pt。如果从PDF的变换矩阵里能拿到图片在页面上显示的pt宽高直接乘以12700就是EMU// widthPt和heightPt来自PDF矩阵中的显示尺寸 int emuWidth (int) (widthPt * 12700); int emuHeight (int) (heightPt * 12700); run.addPicture(new FileInputStream(imgFile), Document.PICTURE_TYPE_PNG, imgFile, emuWidth, emuHeight);顺便说一句PDF里那些“公式图片”也是这个逻辑处理。如果你想转成可编辑的公式得走公式识别再转LaTeX或MathML的路线工作量不小。如果只追求内容可复用把公式保留为图片是性价比最高的务实选择。3.5 表格重建tabula-java能省一半力表格是PDF转Word的另一个重灾区。自己纯靠坐标推理表格结构代码量少说也要上千行。开源社区其实已经有了不错的解决方案tabula-java。它底层基于PDFBox专门分析页面上的文本坐标和线条输出结构化表格。基本用法如下try (PDDocument document PDDocument.load(new File(input.pdf))) { ObjectExtractor extractor new ObjectExtractor(document); Page page extractor.extract(1).next(); // 从第1页开始 TableExtractor tableExtractor new TableExtractor(); tableExtractor.setPage(page); tableExtractor.setUseLineRulings(true); // 优先按可见线条划分单元格 tableExtractor.setUseColumnRulings(false); // 不强制按列线对齐 ListTable tables tableExtractor.extract(); for (Table table : tables) { ListListRectangularTextContainer rows table.getRows(); // 按行列填充到XWPFTable } extractor.close(); }tabula的两个开关参数很值得研究。setUseLineRulings(true)适合那种有线框的表格依据可见线条切分单元格干净利落setUseColumnRulings(true)适合“有线框但不规整”的表格用列对齐来辅助。我实测下来最好先试线框模式如果提取出的行列数异常比如期望5列结果是50列再退回纯文本对齐模式。生成Word表格时用POI的XWPFTable就行XWPFTable table doc.createTable(rowCount, colCount); for (int r 0; r rowCount; r) { for (int c 0; c colCount; c) { table.getRow(r).getCell(c).setText(cellText[r][c]); } }3.6 中文字体映射与写回Word的技巧中文PDF转到Word最常见的两个幺蛾子一是提取出来的中文乱码二是Word打开后中文字体显示不对。乱码的原因多数出在PDF的字体映射上。PDF里嵌的字体如果没有完整的ToUnicode映射提取方只能拿到字形ID拼不出正确的Unicode字符。这种情况在国产打印驱动生成的PDF里尤其频繁。我的排查套路是先提取一小段文本看字符是否正常一旦发现乱码比例偏高直接把这页转OCR比在字体映射里死磕要快得多。字体显示不对则是写Word时忽略了一个细节run.setFontFamily(宋体)在某些POI版本里只设置了西文字体中文字符仍走默认主题字体在Word里打开看起来非常违和。更稳妥的做法是直接操作底层XML设置eastAsia字体CTRPr rpr run.getCTR().isSetRPr() ? run.getCTR().getRPr() : run.getCTR().addNewRPr(); CTFonts fonts rpr.isSetFonts() ? rpr.getFonts() : rpr.addNewFonts(); fonts.setAscii(Times New Roman); fonts.setHAnsi(Times New Roman); fonts.setEastAsia(宋体);另外建议维护一张PDF字体名到Word字体的映射表。PDF内部的字体名通常是“SimSun”“SimHei”“MicrosoftYaHei”这种英文别名对应到Word里就是宋体、黑体、微软雅黑。用ConcurrentHashMap缓存一下转换时按字号批量套用处理速度会快很多。3.7 顺带把多级标题和大纲层级一起还原批量转换最容易被忽略的是“导航结构”。Word文档的价值不只是能编辑还体现在左侧导航窗格可以点击标题跳转。如果转换出的Word没有大纲级别几十上百页的文档用起来依然痛苦。判断标题别只看字号我踩过几次坑之后总结的规则是粗体、字号比正文大、行距明显偏大、且没有缩进这四者同时满足时判定为标题的概率非常高。判断出标题后给这个段落设置大纲级别CTPPr ppr para.getCTPPr(); CTOutlineLvl lvl ppr.isSetOutlineLvl() ? ppr.getOutlineLvl() : ppr.addNewOutlineLvl(); lvl.setVal(0); // 0表示一级标题1表示二级以此类推这样生成的Word目录和大纲导航就能用。另外多级标题的判断顺序建议从大到小先识别一级标题字号最大、居中或顶格再逐级往下。转换完成后留一个人工抽检环节重点抽查标题级别标错的页面比完全靠算法硬啃要稳。4. 常见问题排查与批量转换工程化这个章节是我从实际项目里一点一点攒出来的经验。先给一张速查表再展开讲几个“重灾区”。4.1 高频问题速查表问题现象根本原因解决思路提取的中文全是乱码字体缺ToUnicode映射改走OCR或寻找更规范的PDF源文件扫描件转出空白页页面只有图片没有文本层按页探测后接入OCR引擎多栏文档段落顺序错乱按内容流顺序输出而非视觉顺序用坐标按“先按y分行、再按x排序”重排转换后图片位置偏移PDF坐标原点和Word不一致换算坐标y方向按页面高度翻转表格行列错乱线框和文本对齐模式选错尝试切换tabula的两个参数开关docx文件打不开文本中带XML非法控制字符写入前清洗字符串生成的Word体积巨大图片被重复转码成PNG原图是JPEG就直接原样拷贝字节流大PDF报OOM整个文档一次性加载按页分批处理调整JVM堆内存4.2 几个必须展开讲的“重灾区”扫描件OCRTesseract是免费的tess4j封装得很好用但要得到理想效果关键是预处理。扫描件先转成300 DPI以上的灰度图再做二值化识别率会明显提升。直接拿200 DPI的彩色扫描件识别准确率差一大截。代码上就是常规操作ITesseract tesseract new Tesseract(); tesseract.setDatapath(/path/to/tessdata); tesseract.setLanguage(chi_sim); String result tesseract.doOCR(new File(scan_page.png));不过说实话Tesseract对中文排版复杂的页面效果一般。如果预算允许PaddleOCR的表格还原能力要强不少Java侧通过HTTP调一下服务也很方便。OCR这块我的体会是能用文本层解决的就别上OCROCR是最后手段。图片位置偏移PDF坐标原点在左下角y轴向上而Word页面坐标原点在左上角y轴向下。插入图片时如果直接照搬PDF里的y坐标图片会跑到页面顶部甚至页面外。换算公式是wordY pageHeight - pdfY - imageHeight。所有版面元素文字、图片、表格都要做这个翻转漏掉一个就全部错位。大文件OOMPDFBox 2.x加载超大PDF时会把大量数据读进内存几百MB的扫描件极容易OOM。我的做法是按页分批处理一次处理10页写一批释放一批PDFTextStripper stripper new PDFTextStripper(); for (int p 1; p pageCount; p 10) { stripper.setStartPage(p); stripper.setEndPage(Math.min(p 9, pageCount)); String batchText stripper.getText(document); // 把这一批文本写入Word写完立即释放引用 }同时把JVM参数设置上-Xmx2g起步。如果文档实在太大建议换PDFBox 3.x它对超大文档默认使用临时文件缓存内存友好很多。docx打不开这个坑尤其隐蔽。PDF提取的文本里可能包含控制字符比如\u0000这种直接写进docx的XML会导致文件“已损坏”无法打开。写入前必须清洗private String sanitize(String s) { if (s null) return ; return s.replaceAll([\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F], ); }4.3 批量转换的并发与资源控制批量处理几千个文件时单线程转一天都转不完。我的方案是线程池并发处理多个PDFExecutorService pool Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()); for (File pdf : pdfList) { pool.submit(() - convertPdfToWord(pdf)); } pool.shutdown(); pool.awaitTermination(1, TimeUnit.HOURS);但注意两个线程安全细节POI的XWPFDocument不是线程安全的每个线程必须new自己的实例不能共用PDFBox的PDDocument也是单线程内使用。另外并发数别贪多IO密集和CPU密集混合的任务线程数设为CPU核数附近通常最优加太多只会让磁盘和GC先扛不住。批量任务上线前强烈建议先做个“样本体检”random选50页有代表性的PDF统计文本提取率、表格识别率、单页平均耗时再决定整个任务要跑多久、需要准备多少临时磁盘空间。这一步做不好任务跑到一半才发现大量文档是扫描件全盘返工的成本极高。5. 不想从零造轮子替代方案和数据安全边界如果自建管线实在投入不起商业库确实是更省力的方向。5.1 商业库到底有多省事Aspose.PDF的Java版极其暴力转换核心代码就一行com.aspose.pdf.Document pdf new com.aspose.pdf.Document(input.pdf); pdf.save(output.docx, SaveFormat.DocX);就这一行大部分规整的PDF转出来已经相当像样。Spire.PDF也是类似用法。它们能这么省事是因为把“坐标聚类、段落判断、表格识别”这些逻辑都封装在内部了。但别把商业库神话。我试用时发现遇到某些带复杂艺术字、旋转文本、特殊字体嵌入的PDF商业库一样会排版错乱。商业库解决的是“八成文档的九成问题”剩下两成特殊文档依然需要人工兜底或者是自建管线的混合处理。所以我的建议一直是混合策略先把文档分流规整的走开源管线硬骨头丢给商业库两边结果在一个抽象接口后面切换。5.2 数据安全边界怎么划这一点必须单独说。网上那些免费在线PDF转Word工具确实方便但把合同、标书、内部技术文档传上去等于把机密文件交给陌生人保管风险极高。我见过不止一个单位因为员工用在线转换工具导致文件外泄的通报。企业内部做这类工具底线是“数据不出内网”要么自建转换服务要么在本地部署商业库要么用开源方案自己封装。扫描件涉及OCR的OCR引擎也建议内网部署。数据安全这条红线比技术选型重要得多。最后分享一点个人体会这个项目前后折腾了大半个月最大的收获不是那套转换代码而是对PDF格式本身产生了敬畏。PDF看似简单打开就是个“文档”实际的复杂度全藏在坐标、字体、图像、内容流这些细节里。如果你正准备做类似的需求我的建议是先花两天时间做样本体检把文档类型分布摸清楚再决定技术路线上线前把“文本提取率”和“人工抽检通过率”两个指标定下来后续优化才有明确方向。转换质量这种事永远是“够用就行”和“追求完美”之间的博弈先跑通流程再慢慢打磨细节。