做了几年企业级开发,被“打印”这个需求折腾过不少次。乍一听不就是调个打印机嘛,真上手才发现,JPG转PDF转Word,每种格式背后都是一套完全不同的处理链,网上资料又碎得厉害。最近刚把一套Java打印服务梳理完,顺手把JPG、PDF、Word三种格式的打印方案一次性写清楚,里面包含完整的思路、代码、参数计算和踩坑记录,给准备做OA打印、订单小票打印、后台批量打印的同学一个可以直接落地的参考。
拿到这个需求,大多数人脑海里第一个念头是“Java能直接调用打印机吗”。答案是可以,但“调用打印机”这件事和“把文件按用户预期格式打出来”完全不是一回事。图片要缩放适配纸张,PDF要保持版式,Word最恶心——Java生态里根本没有官方API能直接渲染docx。所以这篇我按“格式分治、统一入口”的思路来讲:图片走Java 2D绘图,PDF走PDFBox,Word先转PDF再打印,中间每一步怎么选型、怎么传参、遇到问题怎么排查,都会用实际代码说明白。
1. 整体思路与方案选型
1.1 Java生态里的几种打印路径
Java里调用打印机,绕不开的核心API是javax.print包下的PrintService和DocPrintJob,以及AWT里的PrinterJob。前者更底层,适合打印文档流和图片流;后者基于Printable回调机制,适合需要自己控制版面、缩放、绘制的场景。我这次采用的是“以PrinterJob为统一出口,按文件格式拆分预处理逻辑”的架构。
有人会问,为什么不直接用PrintService一把梭?原因是PrintService对单个图片和一个简单的PDF能应付,但对多页PDF、需要精确控制纸张方向和边距的场景很别扭。PrinterJob天然支持多页、支持跨平台打印对话框、支持指定PrintService,而且和Java 2D的Graphics2D结合得很好,写图片打印时可以直接控制“画多大的图片、放在页面什么位置”。
三种格式里面,图片打印是最容易的,核心就两步:读图、绘制到Graphics2D。PDF要复杂一些,因为PDF文件本身带有页面尺寸、字体、图层信息,打印时必须让PDF渲染引擎接管,不能像图片那样手动画。Word则完全是另一套逻辑,Java没有官方的docx渲染器,这就决定了我们必须走“转换”这条路。
1.2 三种文件格式的打印难点对比
| 格式 | 主要难点 | 解决方案 |
|---|---|---|
| JPG | 图片分辨率高,直接打印容易超出纸面 | 按纸张可打印区域计算缩放比例,用Graphics2D绘制 |
| 多页文档需要分页,页面大小需与打印机驱动匹配 | 用PDFBox的PDF渲染组件逐页提交 | |
| Word | Java生态无原生渲染器,无法直接打印 | 使用DOCX4J或LibreOffice先转为PDF,再调用PDF打印链 |
这个表格基本就是我整套设计的出发点。我先按格式各自实现,最后统一封装成一个PrintService接口,让上层应用只需传入文件路径和打印机名称,不用关心底层格式差异。
2. 环境准备与核心依赖
2.1 Maven依赖的引入
三种格式对应三套依赖:图片打印不需要额外依赖,JDK自带的ImageIO和java.awt.print就够;PDF打印用Apache PDFBox;Word转PDF我推荐documents4j来做转换中间层,它本质上是调用本机Office或LibreOffice完成转换。如果你不想在服务器上装Office,也可以用LibreOffice headless模式,用命令行的方式把docx转成PDF。
<dependency> <groupId>org.apache.pdfbox</groupId> <artifactId>pdfbox</artifactId> <version>3.0.2</version> </dependency> <dependency> <groupId>com.documents4j</groupId> <artifactId>documents4j-api</artifactId> <version>1.1.9</version> </dependency> <dependency> <groupId>com.documents4j</groupId> <artifactId>documents4j-util-docx</artifactId> <version>1.1.9</version> </dependency>这套依赖选型的核心考量是稳定性和跨平台。PDFBox是Apache的老牌项目,PDF打印这一块非常稳;documents4j虽然底层要依赖本机Office,但它在Linux服务器上切换到LibreOffice后也没问题。注意,PDFBox 3.x把打印相关的类从PDFPageable改成了PDFPrinter,网上很多老帖子的PDFPageable写法在3.x里是跑不通的,这一点我会在PDF章节细说。
2.2 创建打印服务管理器
不管哪种格式,都要先拿到机器上的打印机列表。Java标准方式是通过PrintServiceLookup:
import javax.print.PrintService; import javax.print.PrintServiceLookup; public class PrinterManager { public static PrintService findPrinter(String printerName) { PrintService[] services = PrintServiceLookup.lookupPrintServices(null, null); if (printerName == null || printerName.isEmpty()) { return PrintServiceLookup.lookupDefaultPrintService(); } for (PrintService service : services) { if (service.getName().equals(printerName)) { return service; } } throw new IllegalArgumentException("找不到打印机: " + printerName); } public static void listPrinters() { PrintService[] services = PrintServiceLookup.lookupPrintServices(null, null); for (PrintService service : services) { System.out.println(service.getName()); } } }这里有一个很关键的细节:lookupDefaultPrintService()拿到的默认打印机有时候并不是你想要的,尤其是在服务器环境里。服务器上可能装了虚拟打印机、Microsoft Print to PDF,这些也会出现在打印机列表里。所以生产环境一定要允许上层传入打印机名称,而不是直接默认拿系统默认打印机。
3. JPG图片打印的实现
3.1 图片读取与缩放计算
图片打印最核心的算法是缩放计算。Java打印单位是72dpi,1个打印机点约等于1/72英寸。也就是说,一张600x800像素的图片,如果直接按1像素=1点去打印,实际打印尺寸是600/72 × 800/72 = 8.33 × 11.11英寸,即21.17 × 28.22厘米。绝大多数A4打印机的可打印区域是20.9 × 29.5厘米左右,所以这张图刚好铺满A4纸宽。但如果客户发来一张4000x3000的高清相机图,直接打就会超出纸面。
import javax.imageio.ImageIO; import java.awt.*; import java.awt.print.*; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; public class ImagePrintTask implements Printable { private BufferedImage image; // 页边距,单位是1/72英寸,设置为0.5英寸 private static final double MARGIN = 0.5 * 72; public ImagePrintTask(String imagePath) throws IOException { this.image = ImageIO.read(new File(imagePath)); } @Override public int print(Graphics graphics, PageFormat pageFormat, int pageIndex) { if (pageIndex > 0) { return NO_SUCH_PAGE; } Graphics2D g2d = (Graphics2D) graphics; g2d.translate(pageFormat.getImageableX(), pageFormat.getImageableY()); double printableWidth = pageFormat.getImageableWidth(); double printableHeight = pageFormat.getImageableHeight(); double imageWidth = image.getWidth(); double imageHeight = image.getHeight(); // 计算缩放比例,保证图片等比缩放且不超出可打印区域 double scale = Math.min(printableWidth / imageWidth, printableHeight / imageHeight); int drawWidth = (int) (imageWidth * scale); int drawHeight = (int) (imageHeight * scale); // 居中绘制 int offsetX = (int) ((printableWidth - drawWidth) / 2); int offsetY = (int) ((printableHeight - drawHeight) / 2); g2d.drawImage(image, offsetX, offsetY, drawWidth, drawHeight, null); return PAGE_EXISTS; } public static void print(String fileName, String printerName) throws Exception { PrintService printService = PrinterManager.findPrinter(printerName); PrinterJob job = PrinterJob.getPrinterJob(); job.setPrintService(printService); job.setPrintable(new ImagePrintTask(fileName)); PageFormat pf = job.defaultPage(); pf.setOrientation(PageFormat.PORTRAIT); // 静默打印,true表示显示打印对话框,这里选false if (job.printDialog()) { job.print(); } else { // 静默打印直接调用job.print() job.print(); } } }这段话里有两个容易踩坑的地方。第一,Graphics2D默认的origin在页面左上角,但打印机可打印区域并不是从0开始的,所以要translate(pageFormat.getImageableX(), pageFormat.getImageableY()),否则图片会被打印机的物理边距吃进去一部分。第二,偏移量offsetX和offsetY必须在缩放之后计算,不然图片不会居中。这个公式我在多个项目中都是用这一套,稳定性很好。
3.2 图片DPI与打印清晰度的关系
很多人在图片打印后会反馈“打印出来模糊”,这通常不是你代码写错了,而是图片本身的打印像素密度不够。按上述计算,一张1000x750的图片在A4纸宽(约20.9cm)上打印,实际每英寸就是1000/8.23 ≈ 121dpi,这个清晰度在近距离看确实一般。
如果要保证打印清晰,建议在打印前判断图片的分辨率和缩放比,低于150dpi时提示客户端提供原图。其实生产环境里更常见的是一张超大长图打一页,这种情况下要考虑按比例缩小到一页,而不是模拟“裁剪预览”,客户要的是完整内容,不是高清局部。
4. PDF打印的实现
4.1 PDFBox 3.x的打印方式
PDF打印,首选Apache PDFBox,它支持读取PDF文件,并提供了完整的打印入口。这里直接给一段可以跑通的代码,重点写注释的地方都是新老API的差异点。
import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.printing.PDFPrinter; import org.apache.pdfbox.printing.Orientation; import javax.print.PrintService; import java.awt.print.PrinterJob; import java.io.File; public class PdfPrintTask { public static void print(String pdfPath, String printerName) throws Exception { PrintService printService = PrinterManager.findPrinter(printerName); try (PDDocument document = Loader.loadPDF(new File(pdfPath))) { PrinterJob job = PrinterJob.getPrinterJob(); job.setPrintService(printService); PDFPrinter printer = new PDFPrinter( document, Orientation.AUTO, false, // showPageBorder,是否显示页面边框 true, // dpi,是否使用最高分辨率打印 0 // dpi,为0时使用PDFBox默认150dpi ); job.setPageable(printer); job.print(); } } }注意,PDFBox 2.x从PDDocument创建时用的是PDDocument.load(File),打印走的是PDFPageable;到了3.x就变成了Loader.loadPDF(File),打印走PDFPrinter。因为很多老项目还停留在2.x,你要么锁定版本号,要么用PDFBox 2.0.27配PDFPageable。我建议新项目直接上3.x,因为3.x对字体渲染和内存管理做了很多优化。
4.2 关于PDF打印的方向和页面大小
Orientation.AUTO是个很实用的参数,它表示让PDFBox自动判断页面方向,横向的PDF页面自动旋转成横向打印,纵向的保持纵向。这个参数强烈建议开启,因为很多客户上传的PDF里有横向表格,如果不自动处理,打印出来会被强行裁切或压缩,观感很差。
PDF里还经常出现一种情况:PDF页面尺寸是自定义的,不是标准A4或A5。多数打印机驱动可以自动缩放,但涉及“适应纸张”时,建议在代码里明确设置job.setPageable(printer)后再通过PrinterJob的defaultPage()调整。我做过一个案例是批量打印A4标签,PDF页面是100x100mm,直接打印出来就打得非常小,后来调整了MediaSizeName为ISO_A4才对齐。
一个更实际的问题:连续打印多页PDF时,尽量避免用PrintService的DocPrintJob直接打印,因为DocPrintJob往往不支持多页PDF的准确渲染,导致只打第一页或者排版错乱。用PrinterJob配合PDFPrinter来打印是最稳的路径。
5. Word打印的实现
5.1 为什么不能直接用POI打印Word
Word是目前三种格式里最麻烦的一个,Java生态里确实没有官方的docx渲染器。很多刚入行的人会想到Apache POI,因为POI能读写docx。但POI的定位是“文档数据操作”,不是“排版渲染”,它无法像Word软件那样按字体、段落、页眉页脚、分页符来绘制页面。你用POI读到的是一堆XML段落和样式,打印时要自己算分页、算字体渲染,这在复杂文档上根本不可行。
所以我不走“POI直接打印”的路线,而是“先转PDF,再走打印链”。转换工具有两个方向:一个是documents4j,它在本机有Office的情况下可以高质量转换;另一个是LibreOffice headless命令行转换,适合服务器环境。我下面把两条路都写出来,方便你根据自己的部署环境选择。
5.2 documents4j转换与LibreOffice命令行备选
documents4j的API设计得比较友好,先这样用:
import com.documents4j.api.DocumentType; import com.documents4j.api.IConverter; import com.documents4j.job.LocalConverter; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.InputStream; import java.io.OutputStream; public class WordToPdfConverter { public static File convert(File inputWord) throws Exception { File outputPdf = File.createTempFile("converted_", ".pdf"); try (InputStream in = new FileInputStream(inputWord); OutputStream out = new FileOutputStream(outputPdf)) { IConverter converter = LocalConverter.builder().build(); boolean success = converter.convert(in).as(DocumentType.DOCX) .to(out).as(DocumentType.PDF) .execute(); if (!success) { throw new RuntimeException("Word转PDF失败"); } } return outputPdf; } }这个方案的优点是转换效果和Word里的版式几乎完全一致,缺点是必须依赖本机Office。如果你的服务器是Linux,没有微软Office环境,那就用LibreOffice:
soffice --headless --convert-to pdf --outdir /tmp/word2pdf sample.docxJava里通过ProcessBuilder调这条命令即可。注意,LibreOffice首次启动较慢,且并发转换时容易冲突,建议串行执行或者复用soffice常驻进程,否则在生产上会遇到大量“转换任务占用CPU却不出文件”的假死问题。
我把两种方案整理成对比表:
| 方案 | 环境要求 | 转换质量 | 并发能力 | 部署成本 |
|---|---|---|---|---|
| documents4j + Office | Windows/Mac有Office | 最高 | 低,需串行 | 高 |
| LibreOffice headless | 跨平台 | 高,略低于Office | 中,可用多个实例 | 中 |
根据我的经验,如果是企业内部小批量打印,documents4j体验最好;如果是服务端批量处理用户上传的Word,建议直接用LibreOffice。
5.3 完整的Word打印流程
所以最终的Word打印流程就变成两条链路:docx先转PDF,再调用上面的PDF打印代码。实际操作中,我会把转换和打印封装成一步:
public class WordPrintTask { public static void print(String wordPath, String printerName) throws Exception { File pdfFile = WordToPdfConverter.convert(new File(wordPath)); PdfPrintTask.print(pdfFile.getAbsolutePath(), printerName); pdfFile.deleteOnExit(); } }这个思路最大的优势是稳定可控。打印效果完全取决于PDF渲染,而PDF的版本格式问题比Word成熟得多,打印异常率能大幅下降。不好的地方是多了一次文件IO,但对大多数场景来说完全可接受。
6. 统一打印服务封装
6.1 接口设计与工厂模式
三种格式都打通了,接下来就是把这套逻辑收口。我希望上层应用只做一件事:传文件路径和打印机名,不用关心内部是哪种格式。接口可以这样设计:
public interface IPrintService { void printFile(String filePath, String printerName) throws Exception; }然后做一个工厂类,根据文件扩展名返回对应实现:
public class PrintServiceFactory { public static IPrintService getService(String filePath) { String lower = filePath.toLowerCase(); if (lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png") || lower.endsWith(".bmp")) { return new ImagePrintServiceImpl(); } else if (lower.endsWith(".pdf")) { return new PdfPrintServiceImpl(); } else if (lower.endsWith(".doc") || lower.endsWith(".docx")) { return new WordPrintServiceImpl(); } throw new UnsupportedOperationException("暂不支持的文件类型: " + filePath); } }这个封装我强烈建议在生产环境执行,因为后续如果新增格式(比如Excel、txt),你只需要加一个实现类,不用改动上层业务逻辑。我见过很多项目的打印代码直接散落在订单导出或报表生成方法里,日志看着都是鸡毛蒜皮的打印问题,实际上改起来牵一发动全身,最后只好重构成这种工厂模式。越早统一,后面越省事。
6.2 多任务队列与异步打印
打印是一个典型的耗时操作,一张大PDF可能打印几十秒,如果在Web请求线程里同步执行,接口就会长时间阻塞。我在项目里一般会用ExecutorService做异步队列,同时给每个任务一个状态对象,前端轮询状态展示“打印中/成功/失败”。
import java.util.concurrent.*; public class PrintTaskQueue { private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(3, r -> { Thread t = new Thread(r, "print-worker"); t.setDaemon(true); return t; }); public static Future<?> submit(String filePath, String printerName) { return EXECUTOR.submit(() -> { IPrintService service = PrintServiceFactory.getService(filePath); service.printFile(filePath, printerName); }); } }线程数不建议设置太高,因为打印机本身有队列,而且本机Office/LibreOffice转换工具对并发敏感。3到5个线程已经能覆盖大部分中小企业后台打印场景。如果任务特别多,应该把文件先落地到磁盘,然后按顺序排队,而不是几百个任务一起挤进线程池,否则内存很快会被PDFBox和转换进程吃满。
6.3 关于静默打印与打印对话框
我在这套代码里用了job.print()直接打印,也就是常说的静默打印,用户不会看到弹窗。这个在服务器端非常重要,因为服务器打印本来就是无人值守。但如果你做的是桌面客户端工具,比如一个给前台用的扫描打印小工具,那可能还是需要弹出job.printDialog()让用户选打印机、改份数。
这里有一个细节:job.printDialog()会重新初始化床位,如果你之前已经job.setPrintService指定了打印机,弹窗里会默认选中那台;但用户点确定后,实际使用的打印机可能被用户改了,所以打印结果判断要以printDialog之后的job.getPrintService()为准,不要用你自己set进去的printerName来记录日志。这个我踩过一次坑,生产上日志显示打了A打印机,实际用户弹窗里选成了B打印机,排查了很半天才反应过来。
7. 常见问题与排查技巧实录
7.1 高频异常对照速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 打印空白页 | 图片缩放后尺寸为0或PDF页面是空白 | 检查读图是否成功,PDF里的字体是否缺失 |
| 图片打印到纸张左上角且被裁切 | 没有正确translate(pageFormat.getImageableX(), ...) | 打印前加上可打印区域平移 |
| 打印方向错误,横向页面被压扁 | 未启用Orientation.AUTO | PDF打印时设置自动方向 |
| Word转换超时或转出来的PDF空白 | 本机Office未安装或LibreOffice并发冲突 | 串行执行转换,或切换转换方案 |
| 打印出来的中文字体变成方块 | 打印用的渲染引擎缺少中文字体 | Linux服务器安装fonts-noto-cjk字体包 |
| 静默打印没有任何报错但不出纸 | 打印机驱动挂起或作业被暂停 | 检查打印队列重启spooler服务 |
7.2 字体缺失问题的排查思路
打印中文字体变成方块,在Linux服务器上尤其常见。因为很多精简版Linux镜像不会安装中文字体,PDFBox渲染PDF或LibreOffice转换Word时找不到字体,就会用默认字体兜底,而默认字体不支持中文,就显示为方块或问号。
解决办法是给服务器安装中文字体,Ubuntu/Debian系执行:
sudo apt-get install -y fonts-noto-cjkCentOS系执行:
sudo yum install -y wqy-zenhei-fonts wqy-microhei-fonts装完字体后,建议重启Java进程,因为字体缓存通常在JVM启动时加载。这个问题我在最初做PDF打印时折腾了快两天,最后排查到字体层面才解决。所以提醒你:线上环境一旦出现打印中文乱码,先别急着改代码,先用fc-list :lang=zh看一眼服务器有没有中文字体,很多时候这一步就能省掉大量调试时间。
7.3 打印队列阻塞与作业假死
生产环境里打印机队列假死是最恼人的问题。表面的现象是Java代码没有异常,但打印任务一直卡在队列里,别的任务也打印不出来。排查思路一般是:先看操作系统打印队列(Windows按Win+R输入services.msc找到Print Spooler,Linux用lpstat -o),队列里如果有状态为“暂停”或“错误”的作业,直接删除作业,再重启打印服务。
从代码层面降低假死概率的方法是设置合理的PrintRequestAttributeSet,给作业一个明确的名称和优先级:
import javax.print.attribute.HashPrintRequestAttributeSet; import javax.print.attribute.PrintRequestAttributeSet; import javax.print.attribute.standard.JobName; import javax.print.attribute.standard.JobPriority; PrintRequestAttributeSet attrs = new HashPrintRequestAttributeSet(); attrs.add(new JobName("order_20240601_001", Locale.CHINA)); attrs.add(new JobPriority(50));给打印作业起一个和业务关联的名字,排查问题的时候一眼就能在打印机队列里认出是哪个业务在打,这对维护多套打印服务的系统特别有价值。JobPriority默认是50,不要随意改成99,因为重要作业和普通作业都需要打,优先级拉满后可能让普通作业长期饿死。
7.4 文件清理与临时目录管理
Word转PDF会产生大量临时文件,documents4j默认生成的临时文件放在系统临时目录。如果打印服务长期运行,这些文件不清理会把磁盘占满,最终导致转换失败或打印失败。建议在转换完成后立即删除临时PDF,同时定期清理临时目录里超过一天的文件。
outputPdf.deleteOnExit(); // 进程退出时删除,但长期运行的服务不触发更好的做法是转换完打印结束后立即删除:
try { PdfPrintTask.print(pdfFile.getAbsolutePath(), printerName); } finally { pdfFile.delete(); }这里有个矛盾点:打印是异步的,job.print()返回时打印机可能还在读文件,立即删除文件在一些打印机驱动上会导致打印失败。稳妥的做法是睡眠一到两秒再删除,或者干脆留到下一次启动任务前统一清理。我一般使用后者:每次转换前扫描临时目录,把超过2小时的文件清理掉,既不干扰正在打印的文件,也能防止磁盘溢出。
7.5 高分辨率大图的内存控制
处理大图片时,ImageIO.read会把整张图加载进内存。一张6000x4000的相机原图,按每个像素4字节算,内存占用是6000×4000×4 ≈ 96MB,如果是几十张图批量打印,内存很快会被吃满。处理这种情况,可以在读图时指定采样缩小:
import javax.imageio.ImageIO; import javax.imageio.ImageReader; import javax.imageio.stream.ImageInputStream; import java.awt.image.BufferedImage; import java.io.File; import java.util.Iterator; public class ImageUtil { public static BufferedImage loadWithScale(String path, int maxWidth, int maxHeight) throws Exception { try (ImageInputStream iis = ImageIO.createImageInputStream(new File(path))) { Iterator<ImageReader> readers = ImageIO.getImageReaders(iis); if (!readers.hasNext()) { throw new RuntimeException("无法识别的图片格式: " + path); } ImageReader reader = readers.next(); try { reader.setInput(iis, true, true); int width = reader.getWidth(0); int height = reader.getHeight(0); int scale = 1; while (width / (scale * 2) >= maxWidth && height / (scale * 2) >= maxHeight) { scale *= 2; } return reader.read(0, new javax.imageio.ImageReadParam() { { setSourceSubsampling(scale, scale, 0, 0); } }); } finally { reader.dispose(); } } } }setSourceSubsampling能按2的倍数降低采样率,读取出来的图像分辨率变小,内存占用大幅下降。这种优化策略对打印不重要,因为打印本身就是按纸张大小缩放,只要你设置的maxWidth/maxHeight足够大(比如A4打印1560×2000),打印效果不会受影响。
最后的一些体会
整套方案跑下来,我最大的感受是“打印这个需求,难的不是写代码,而是理解打印链路”。Java打印层层叠叠:文件格式、渲染引擎、打印机驱动、操作系统打印队列,任何一环出问题都会表现为“没打出来”或者“打出来不对”。所以我的建议是,做打印功能一定要保留完整的链路日志,至少要在代码里记录下文件路径、打印机名、作业名、渲染耗时和打印耗时,排查时你会发现这几行日志比什么调试工具都管用。
一个小技巧收尾:开发阶段别用真实打印机,Windows自带的Microsoft Print to PDF就是一个极佳的测试目标,它能把你程序发出的所有打印任务转成PDF文件,这样你可以离线检查程序有没有“真的把内容打出来、占据对不对”。我后面做类似需求时,都会先以虚拟打印机验证代码逻辑,再切到实体打印机跑兼容性测试,效率会高很多。