刚做完一个跟Java打印PDF有关的交付,正好把整套思路和踩过的坑整理出来。需求不复杂:业务系统里生成了一批PDF报告,要直接从后端送打印机,不弹Windows打印对话框,运维机上还没装Java运行时,程序得打成exe双击就能跑。最后我选定的技术路线是pdfbox做PDF加载和打印,web端只负责触发,本地跑一个小型打印代理接收请求,再用exe4j把jar包转成exe,顺带把JRE一起捆进去。这篇把完整链路、关键代码和现场踩坑一次性写清楚,适合正在做Java Web打印,或者想把jar包装成Windows可执行程序的开发者参考。
1. 需求拆解:Java打印PDF为什么会变成一件麻烦事
1.1 打印需求里最容易忽略的三个隐藏点
表面上看“打印PDF”就是加载文件、调打印机,但实际交付时往往藏着三个隐藏点。
第一是静默。用户不希望点击“打印”后弹出Windows那个又大又旧的打印对话框,他们要的是点击后打印机直接出纸。这个需求在报表、仓储拣货、医疗报告这类场景里几乎是标配,因为操作员一天要打几十上百张,每张都弹窗选一次打印机、点一次确定,效率完全没法看。Java里做静默打印并不复杂,核心是PrinterJob.print()不带参数、不调用printDialog(),但要注意打印服务选择和纸张配置必须提前做好,否则打出来的格式对不上。
第二是目标打印机不固定。同一个系统里可能有针式打印机、激光打印机、标签打印机好几种,每台的名字还可能是“EPSON LQ-300K”或者“TSC TTP-244 Pro”这种带空格的。程序里不能用默认打印机一把梭,必须支持按名字精确匹配,匹配不到时要给出明确报错而不是悄悄打到别的机器上。
第三是运行环境的不可控。客户的生产机器可能是台装了Windows的老电脑,没有JDK,甚至不允许装Java环境。这类场景下jar包是没法直接用的,必须打包成exe,并且要把JRE一起带上,让程序在无Java前置条件下也能运行。这一步看起来是打包工具的配置问题,实际牵连到路径、会话、字体等一系列细节,后面单独讲。
1.2 技术选型的分岔口:解析、打印、部署三条线
围绕标题里的几个关键词,整个任务可以拆成三条独立的线:
- 解析与渲染线:用pdfbox读取PDF,生成可打印的对象,交给Java的打印框架。
- 触发与集成线:Java Web程序如何把打印动作落到客户端本地的打印机上。
- 打包与发布线:jar包如何通过exe4j变成exe,以及JRE怎么捆绑。
这三条线之间没有强依赖,可以分别测试再组合。我的做法是先写一个纯命令行版本的打印工具,参数就是“打印机名 + PDF路径”,在开发机上直接把打印链路验证通过,再套Web代理的外壳,最后才做exe打包。这样做的好处是每一条线的排查边界很清晰:打印不出来就回到命令行版本去跑,跟Web和exe都无关。
选型上,解析PDF没有太多悬念,pdfbox是Apache顶级的Java PDF库,内置了打印相关的PDFPrintable和PDFPageable类,直接和Java的PrinterJob对接,这是它最大的优势。相比之下,有些库强在PDF生成,让它们去“打印一份已有的PDF”反而绕,还得自己逐页渲染成图片再送打印机,效率和清晰度都差一些。
2. pdfbox静默打印:从PDF解析到打印机队列的完整链路
2.1 PDFBox打印的基本原理
pdfbox打印的本质,是把你传入的PDDocument包装成Java打印框架能识别的Printable对象。Java的PrinterJob负责跟操作系统打印子系统打交道,pdfbox负责把PDF的每一页渲染成打印设备需要的图形指令。两者通过java.awt.print包里的接口衔接,整个流程是:
PDDocument.load(...)加载PDF文件。new PDFPrintable(document, Scaling.SHRINK_TO_FIT)创建可打印对象,里面已经包含了缩放策略。PrinterJob.getPrinterJob()拿到当前进程的打印任务。job.setPrintService(service)指定目标打印机。job.setPrintable(printable)或job.setPageable(pageable)绑定内容。job.print()静默提交。
需要注意,print()是把数据提交到Windows打印假脱机队列就返回了,并不是“打印完成”的通知。打印机后面的故障、缺纸、卡纸,Java这边在调用返回时是感知不到的。这是所有Java打印方案都绕不开的边界,后面第5节会讲怎么处理。
2.2 静默打印的最小可用代码
下面这个类就是整个工具的核心,命令行直接可以跑:
import java.awt.print.PrinterJob; import java.io.File; import javax.print.PrintService; import javax.print.PrintServiceLookup; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.printing.PDFPrintable; import org.apache.pdfbox.printing.Scaling; public class PdfPrintTool { public static void main(String[] args) throws Exception { // 用法: java -jar pdf-print.jar "打印机名称" "D:\files\report.pdf" if (args.length < 2) { System.err.println("Usage: pdf-print <printerName> <pdfPath>"); System.exit(2); } String printerName = args[0]; String pdfPath = args[1]; printPdf(printerName, pdfPath); } public static void printPdf(String printerName, String pdfPath) throws Exception { try (PDDocument document = PDDocument.load(new File(pdfPath))) { PrinterJob job = PrinterJob.getPrinterJob(); PrintService target = findPrintService(printerName); if (target == null) { throw new IllegalStateException("没有找到打印机: " + printerName); } job.setPrintService(target); // 关键:不要调用 job.printDialog(),print() 本身就是静默提交 job.setPrintable(new PDFPrintable(document, Scaling.SHRINK_TO_FIT)); job.print(); } } private static PrintService findPrintService(String name) { PrintService[] services = PrintServiceLookup.lookupPrintServices(null, null); for (PrintService service : services) { if (service.getName().equalsIgnoreCase(name)) { return service; } } return null; } }很多人第一次写会在job.print()之前顺手加一句if (job.printDialog()),觉得这样用户能确认一下。静默打印恰恰相反,任何弹窗都是多余动作。去掉弹窗,这个工具就具备了静默属性。
依赖方面,如果用的Maven,加这一条就够:
<dependency> <groupId>org.apache.pdfbox</groupId> <artifactId>pdfbox</artifactId> <version>2.0.30</version> </dependency>我用的是2.0.x分支,稳定且资料多。3.x系列API的打印类位置基本兼容,如果项目本来就用了3.x,直接换包名即可,核心逻辑不用动。
2.3 纸型、边距和缩放的正确调法
PDF本身是有固定页面尺寸的,但实际打印机默认的纸张可能跟PDF不一致,尤其从别的业务系统生成的PDF,页面设置五花八门。这时要靠PageFormat和Paper来约束输出。
import java.awt.print.PageFormat; import java.awt.print.Paper; Paper paper = new Paper(); paper.setSize(595.2756, 841.8898); // A4,单位是1/72英寸 paper.setImageableArea(0, 0, paper.getWidth(), paper.getHeight()); PageFormat pageFormat = new PageFormat(); pageFormat.setPaper(paper); PDFPrintable printable = new PDFPrintable(document, Scaling.SHRINK_TO_FIT, false); job.setPrintable(printable, pageFormat);这里paper.setSize的单位是点(point),1点是1/72英寸。A4纸是210mm×297mm,换算出来就是约595×842点。setImageableArea设置可打印区域,如果打印机不支持无边距,区域四周要留几毫米,具体数值参考这台打印机的驱动说明。
缩放策略是另一个容易踩坑的点,pdfbox的Scaling枚举有六种行为:
| 枚举值 | 行为说明 |
|---|---|
| ACTUAL_SIZE | 按PDF原始尺寸输出,不做缩放 |
| SHRINK_TO_FIT | 等比缩放,只缩小不放大,内容不会超出纸张 |
| SCALE_TO_FIT | 等比缩放,填满整个可打印区域 |
| STRETCH_TO_FIT | 非等比拉伸,可能变形 |
| SMALLER_THAN_ACTUAL | 只有原始尺寸大于纸张时才缩小 |
| LARGER_THAN_ACTUAL | 只有原始尺寸小于纸张时才放大 |
我一般默认用SHRINK_TO_FIT。原因是PDF实际尺寸跟打印纸差一点的情况最常见,小比例缩小不影响可读性,而STRETCH_TO_FIT会把文字拉变形,条码和表格尤其不能这么干。
2.4 多打印机选择与默认打印机兜底
代码里用了PrintServiceLookup.lookupPrintServices(null, null)拿全部打印机,然后按名字精确匹配。这里有个经验:打印机名字在Windows里可能有大小写和空格差异,比如“TSC TTP-244 Pro”看起来正常,但打印队列里可能显示成“TSC TTP-244 Pro (副本 1)”。所以匹配时用equalsIgnoreCase,并且建议在代理接口里允许传“模糊关键字”,比如传“TTP-244”就能匹配到包含这个字符串的第一台设备。
如果匹配不到,不要静默失败。我的做法是先把默认打印机作为兜底,同时记录一行warn日志:
PrintService fallback = PrintServiceLookup.lookupDefaultPrintService(); if (target == null && fallback != null) { System.err.println("目标打印机不存在,使用默认打印机: " + fallback.getName()); target = fallback; }生产环境里“打印机名字变了”是高频事故,光看名称匹配不够,最好在Web管理界面让用户维护一份打印机别名表,界面上下拉选择,后端存的是别名对应的真实打印机名。这样换打印机不影响业务代码。
3. web端调用打印的架构取舍:本地代理服务是更省心的那条路
3.1 浏览器直打与本地代理的本质区别
Java Web程序里要触发本地打印,业界其实没有银弹。浏览器直接调用window.print()只能打印网页,而且必然会弹打印对话框,也几乎没有能力指定“用哪一台本地打印机”,只能让用户在浏览器对话框里选。这在内部系统里很痛苦:用户每打一次都要重新选打印机,选错了还得去打印机那里等一张废纸。
有过一段时期大家用Java Applet或者ActiveX控件在页面里调打印机,随着浏览器插件的消亡,这条路已经基本废了。所以现在我认为最可靠、适配面最广的架构是:在客户端装一个小型本地代理exe,业务系统后端的Java代码通过HTTP把打印任务发给代理,代理进程在客户端本地调用pdfbox完成静默打印。
这个架构的本质是把“浏览器不能碰本地打印机”的问题,转换成“客户端有一个常驻进程帮你碰本地打印机”。它不需要浏览器插桩,也不需要特殊权限配置,只要客户端能启动一个exe,且能访问localhost端口就行。
3.2 本地打印代理的进程模型与端口约定
代理在客户端以exe形式运行,启动流程是:
- 代理启动时先检查端口是否被占用,如果被占用说明已有实例在运行,直接把自己退出,避免双开后打印任务错乱。
- 绑定
127.0.0.1和固定端口,比如8120,不对外网网卡暴露。 - 后台开启一个HTTP接口,业务服务器把“打印机名 + PDF文件路径(或Base64内容)”POST过来。
- 代理接收后调用
PdfPrintTool.printPdf,完成静默打印,返回ok或错误信息。
端口为什么只绑127.0.0.1?这是安全考虑。打印代理一旦绑到0.0.0.0,同网段的任何机器都可能向它发打印请求,因为接口通常没有复杂鉴权,这是一台“无门禁的打印机”。绑定回环地址后,只有本机程序能调用,最稳妥。
3.3 用JDK自带HttpServer实现打印接口
很多人以为要引入Spring Boot或者Jetty才能起HTTP服务,其实JDK自带的com.sun.net.httpserver.HttpServer足够应付这种轻量接口,连额外依赖都不用加。
import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; import java.io.IOException; import java.io.OutputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; public class PrintAgent { public static void main(String[] args) throws IOException { int port = 8120; HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0); server.createContext("/print", PrintAgent::handlePrint); server.start(); System.out.println("print agent started at http://127.0.0.1:" + port); } private static void handlePrint(HttpExchange exchange) throws IOException { try { String query = exchange.getRequestURI().getQuery(); String printerName = parseParam(query, "printer"); String pdfPath = parseParam(query, "file"); if (printerName == null || pdfPath == null) { throw new IllegalArgumentException("缺少 printer 或 file 参数"); } // 这里调用前面写的PdfPrintTool,或用业务封装类 PdfPrintTool.printPdf(printerName, pdfPath); sendResponse(exchange, 200, "ok"); } catch (Exception e) { sendResponse(exchange, 500, e.getMessage() == null ? "error" : e.getMessage()); } } private static String parseParam(String query, String key) { if (query == null) { return null; } for (String pair : query.split("&")) { String[] kv = pair.split("=", 2); if (kv.length == 2 && kv[0].equals(key)) { return kv[1]; } } return null; } private static void sendResponse(HttpExchange exchange, int code, String msg) throws IOException { byte[] bytes = msg.getBytes(StandardCharsets.UTF_8); exchange.sendResponseHeaders(code, bytes.length); try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); } } }这段代码是演示级的,生产环境必须做几处加强:接口改成POST、PDF用Base64传内容而不是本地路径、加上简单的token校验、对并发打印任务做排队。但核心骨架已经足够说明代理的工作方式了。
3.4 代理侧的并发与任务排队
如果业务系统里同时有人点打印,代理进程就可能同时收到多个handlePrint请求。job.print()本身是提交到假脱机队列,同一时间多点几次问题不大,但基于几百台机器的实际教训,还是建议在代理侧加一个简单的Semaphore或线程池,把并发控制在4以内,避免瞬间生成的打印任务把某台共享打印机队列塞爆。
private static final java.util.concurrent.Semaphore PRINT_LOCK = new java.util.concurrent.Semaphore(4);在handlePrint里先acquire(),结束时release(),异常时也要确保释放。这种方式比复杂消息队列简单,又比裸奔可控得多。
4. exe4j打包:jar包变成exe的关键配置与JRE捆绑
4.1 打包前准备:JDK版本、依赖清单和主类
进入exe4j之前,先把jar包整理干净。最省事的是用Maven的shade插件打一个可执行fat jar,把所有依赖打进同一个jar,这样exe4j的classpath配置就只需要一个文件。
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.5.2</version> <executions> <execution> <phase>package</phase> <goals> <goal>shade</goal> </goals> <configuration> <transformers> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <mainClass>com.demo.printagent.PrintAgent</mainClass> </transformer> </transformers> </configuration> </execution> </executions> </plugin>打包前还要确认主类方法真的能脱离IDE跑。我把命令行版PdfPrintTool和代理版PrintAgent分开,因为它们的启动参数不一样。代理版直接java -jar print-agent.jar启动后常驻,打印任务由HTTP接口触发,这种模式最适合exe化。
4.2 exe4j配置步骤:从向导到生成exe
exe4j的图形化向导一共几页,关键配置点并不多:
- 新建工程时选择
Jar in EXE,这是最常见的类型,表示我们的Jar里包含了全部依赖。 - Executable Information里填exe文件名,可以附带图标和启动画面,64位机器建议勾选64位偏好。
- Java Invocation页面是核心:Classpath里添加我们打好的fat jar,Main Class填
com.demo.printagent.PrintAgent,VM Parameters填-Xmx256m -Dfile.encoding=UTF-8。 - JRE Search页面配置JRE查找顺序,把捆绑的
jre目录放在最前面,然后是系统JAVA_HOME,最后才是注册表里已有的Java环境。 - 最后Generate就能产出exe。
-Dfile.encoding=UTF-8这条我建议无条件加上。Windows默认的GBK编码在解析带中文路径、中文参数的PDF时经常出幺蛾子,指定UTF-8能让文件路径和HTTP参数的中文处理更稳定。
4.3 处理JRE的两种方式:自带jre目录与jlink精简运行时
打包exe只是第一步,没带JRE的exe换个没装Java的机器照样起不来。这个问题有两种解法:
第一种最省事,适合JDK 8时代留下的习惯:把JDK里的jre目录整个复制到exe旁边。但JDK 9以后不再单独提供jre目录,所以这条路对新的JDK版本不太适用。
第二种是用jlink做一个精简运行时。我的PrintAgent只用了HTTP服务、AWT打印、PDFBox和基础日志,模块裁剪出来很小。Windows命令大致长这样:
jlink --module-path "%JAVA_HOME%\jmods" ^ --add-modules java.base,java.desktop,java.logging,jdk.httpserver ^ --output jre ^ --strip-debug --no-header-files --no-man-pagesjava.desktop里包含java.awt.print整套打印框架,是PDF打印必需的。jdk.httpserver是前面用的HTTP服务模块,如果代理改成纯Socket实现,这个模块可以不加入。生成的jre目录一般几十MB到一百多MB,可以接受。
jlink运行时放好后,在exe4j的JRE Search里添加一条相对路径jre,它就会优先查找exe所在目录下的jre子目录。这样整个交付包就是exe + jre目录 + 可能需要的配置文件,拷到目标机器即可运行。
4.4 打包前后行为差异:GUI与Console模式的选择
exe4j里有一个很容易被忽略的选项:Executable Page里的“Mode”,有GUI和Console两种。GUI模式下双击exe不会出现黑窗口,适合作为后台代理常驻;Console模式下stdout会写到控制台,适合开发调试版本。我建议保留两个构建配置:调试用Console版本,能直接看到System.out.println的日志;交付用GUI版本,界面干净。
但GUI模式的一个副作用是,如果程序启动后抛异常,你什么都看不见。所以打包进exe的程序必须有落盘的日志机制,这个放到第5节讲。
5. 部署到真实环境的踩坑记录与排查经验
5.1 中文乱码和缺字体:很可能不是PDFBox的锅
在开发机上一切正常,换成客户机器后PDF里的中文变成豆腐块,这是我在部署阶段遇到的第一个问题。排查下来多数情况是目标机器缺少PDF里引用的中文字体。pdfbox在解析PDF时,如果遇到文档未嵌入的字体,它会尝试用系统字体映射;系统里没装对应字体,渲染出来就是空白或乱码。
解决办法分两层。第一层是给目标机器补字体,宋体、黑体、微软雅黑至少留一套,应用读取用户GUI字体时走系统逻辑字体,基本就能兜住。第二层是如果PDF是你们自己系统生成的,生成时把字体嵌入PDF。嵌入后打印效果跟目标机器装不装字体无关,彻底根治这类问题。
我当时的处理是在PDF生成端把常用字体嵌入,打印端不再依赖目标机器字体,后续再没出现过乱码。
5.2 user.dir的坑:程序找不到文件的第一查项
exe双击启动后,System.getProperty("user.dir")返回的不一定是exe所在目录,而是启动它的那个“当前工作目录”。在快捷方式启动、计划任务启动时,这个值经常变成别的地方,导致程序用相对路径去找PDF、日志、配置文件时全部扑空。
我的习惯是彻底不用相对路径,统一以exe所在目录为基准。exe4j提供了一个属性可以直接拿到exe的绝对路径:
String exePath = System.getProperty("exe4j.exeName"); String baseDir = exePath != null ? new File(exePath).getParent() : new File(PrintAgent.class.getProtectionDomain() .getCodeSource().getLocation().toURI()).getParent();拿到baseDir之后,所有临时目录、日志目录、配置目录都拼在它下面。这样你从资源管理器双击、从命令行启动、从计划任务拉起,路径行为都一致。
5.3 打印机离线与假脱机队列的应对
前面说过,job.print()是异步提交,打印队列后面发生的缺纸、离线和卡纸,调用方感知不到。现实里打印机掉线是家常便饭,所以考虑在代理侧做两层防护。
第一层:提交前粗查打印机状态。通过PrintService的属性读一下PrinterState,常见值是IDLE、PROCESSING、STOPPED,如果已经是STOPPED就直接报告“打印机不可用”,不再往队列里塞任务。需要注意不同驱动实现的属性完整性不一样,这个方法只能做粗滤,不能当作精确状态。
第二层:业务上约定“提交成功=进入队列”,把打印结果查询从实时调用中剥离。业务系统记录一个打印任务号,代理打印后把状态回写;如果用户发现没出纸,用任务号去后台查假脱机状态,而不是让Java打印链路承担过重的故障检测职责。这套逻辑虽然朴素,但实测下来比试图在Java层把打印机状态查得明明白白要可靠得多。
5.4 计划任务、服务运行与交互式会话的问题
如果想把代理做成“开机自动常驻”,很多人第一反应是注册成Windows服务。对打印场景来说,这不一定是最佳选择。Windows服务运行在Session 0,和用户登录后的交互式桌面不在一个会话里,部分打印机驱动在Session 0下加载异常,会出现“服务显示运行中但打印机没反应”的诡异现象。
我用下来更稳的方式是让代理以普通用户进程形式常驻,启动方式用“启动文件夹”或者计划任务里的“用户登录时运行、只在用户登录时运行”。这样做的好处是代理跑在用户的交互式会话里,打印机驱动的行为跟正常点击打印完全一致。代价是每个登录用户会话里都可能有一个代理实例,所以前面说的“端口占用就退出”就变得非常重要,它能避免多个实例抢同一台打印机。
5.5 日志、异常与退出码:调试exe程序的三个抓手
exe打包后没法像IDE那样看控制台,所以我给代理程序加了两个排查抓手。第一个是全局未捕获异常处理器,把任何没被catch住的异常写到临时目录下的日志文件:
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> { String tempDir = System.getProperty("java.io.tmpdir"); Path log = Paths.get(tempDir, "print-agent-error.log"); try { Files.write(log, StackTraceUtils.toString(throwable) .getBytes(StandardCharsets.UTF_8)); } catch (IOException ignored) { throwable.printStackTrace(); } });第二个是日志按级别落盘,至少保留最近几天的文件。排查现场问题时,日志里有每次打印请求的打印机名、PDF路径、提交结果,能极大缩小怀疑范围。
第三个抓手是退出码。代理除了网络常驻模式,还留了命令行入口用于自检,比如PrintAgent --list-printers可以把本机打印机名字全部打印出来。自检命令配合Console模式exe,在客户的机器上跑一下,立刻能确认“是程序没找到打印机”还是“程序找到了但驱动有问题”,这个动作在现场排查时非常省时间。
最后分享一个我自己调试这套东西的小经验:不要只在一台机器上测。我第一版工具在开发机上的共享打印机测试完全正常,拿到现场后因为打印机名带了“(副本 1)”后缀直接匹配失败。从那以后我在交付前都会让现场提供一张PrintAgent --list-printers的真实输出,用真实的打印机名来验收,而不是用我本地编的名字。打印这种功能,离真实环境越早验证,后面返工越少。