做Java开发这些年时不时就要跟打印机打交道。最近接了个项目需求在Java服务端调用本地打印机分别打印JPG图片、PDF文档和Word文档三种格式。听起来不复杂真正做起来才发现坑不少——JPG还好说PDF需要考虑渲染质量和纸张适配Word更是重灾区方案选错了直接白忙活。这篇文章把我整个调研、选型、编码、排坑的过程都记录下来包括每一套方案的适用场景、核心代码和实测结论给需要做Java调用打印机这块的朋友一个可以直接参考的完整方案。这篇内容适合两类人看一是刚接触Java打印、手里有一个打印需求但不知道从哪下手的后端开发二是已经能打印图片、但被PDF排版和Word格式搞得头疼的进阶选手。我尽量把每一步的为什么也讲清楚这样你遇到类似项目时换一套纸型、换一种文档也能自己推出来。1. 项目整体思路与方案选型1.1 三条技术路线按文件类型分开处理接到需求第一步不是写代码而是把Java能操作打印机的路子捋清楚。Java操作打印机大体有三条路线底层是JDK自带的javax.print也就是Java Print ServiceJPSAPI。它可以枚举系统中的打印机、创建打印任务、设置纸张大小和方向支持直接把图片或某些格式的文档送进打印机队列。这套API最大的好处是不依赖第三方库缺点是能力有限——它本身不认识PDF和Word你得先把这些格式翻译成它能处理的东西。中间层是各种解析库PDFBox负责读PDF、POI负责读Word、JACOB负责通过COM调用Windows上的Word程序。它们解决的是文件内容怎么读出来的问题。最上层是系统命令比如调用LibreOffice无头模式、或者用Windows自带的打印命令这属于借助外部程序间接打印的思路。我最后采用的策略是按文件类型分开走。JPG直接用javax.print打印PDF用PDFBox渲染成图片后再交javax.printWord不直接打印而是先转成PDF再走PDF的打印链路。这套组合兼顾了格式还原度、跨平台能力和开发成本是目前我实测最稳的方案。1.2 环境准备与依赖引入含JACOB的坑我的开发环境是Windows 10 JDK 1.8 Spring Boot 2.x。JDK版本不建议低于8javax.print相关API都很老但PDFBox新版本对JDK有要求选个稳定的JDK 8以上版本能省去很多兼容性问题。Maven依赖如下!-- PDF渲染与打印 -- dependency groupIdorg.apache.pdfbox/groupId artifactIdpdfbox/artifactId version2.0.27/version /dependency !-- Word文档内容解析 -- dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version4.1.2/version /dependency !-- Windows COM调用Word打印方案用 -- dependency groupIdcom.jacob/groupId artifactIdjacob/artifactId version1.20/version /dependencyJACOB有个特别容易踩的坑它除了jar包之外还要一个jacob.dll原生库这个dll不是maven自动下载就能用的得手动放到JDK的bin目录、Windows的System32目录或者通过-Djava.library.path指定。而且32位和64位的dll不通用Word是32位而你用的是64位JVM调用时就会提示找不到库。这个我在项目刚开始时折腾了不少时间建议命名dll为jacob.dll直接丢到C:\Windows\System32下省事。注意JACOB方案只能在Windows上运行。如果你的生产环境是Linux直接跳过4.1节的方案A用LibreOffice那个方案更靠谱。另外POM里最好把poi-ooxml和pdfbox都放到dependencyManagement里统一版本管理因为POI的日志依赖有时会跟项目里的其他库冲突统一版本能减少一些莫名其妙的问题。2. JPG打印原生javax.print就够了2.1 核心代码图片打印只需十条APIJPG是三种文件里最简单的一个。因为javax.print原生支持DocFlavor.INPUT_STREAM.JPEG这种文档类型你不需要做任何图片解码直接把文件流交给打印任务就行。下面是一个完整的JPG打印方法import javax.print.*; import javax.print.attribute.*; import javax.print.attribute.standard.*; import java.io.File; import java.io.FileInputStream; public static boolean printJpg(String filePath) { try { // 1. 根据文件流创建打印文档 FileInputStream fis new FileInputStream(filePath); DocFlavor flavor DocFlavor.INPUT_STREAM.JPEG; Doc doc new SimpleDoc(fis, flavor, null); // 2. 构造打印属性A4纸、竖向、打印1份 PrintRequestAttributeSet attrs new HashPrintRequestAttributeSet(); attrs.add(MediaSizeName.ISO_A4); attrs.add(OrientationRequested.PORTRAIT); attrs.add(new Copies(1)); // 3. 查找匹配的打印机服务 PrintService[] services PrintServiceLookup.lookupPrintServices(flavor, attrs); if (services.length 0) { System.err.println(没有找到支持JPG打印的打印机); return false; } PrintService defaultService services[0]; // 4. 创建打印任务并提交 DocPrintJob job defaultService.createPrintJob(); job.print(doc, attrs); fis.close(); return true; } catch (Exception e) { e.printStackTrace(); return false; } }这里用lookupPrintServices(flavor, attrs)而不是直接拿系统默认打印机是因为有的打印机虽然存在但不支持INPUT_STREAM.JPEG这个flavor。如果你直接拿默认打印机创建任务打印出来的可能是一串乱码字符或者干脆报异常。用flavor过滤一下能提前规避这个问题。如果你想把代码写得通用一点可以把flavor参数抽出去让调用方传入是JPG还是PNG甚至传成DocFlavor.INPUT_STREAM.AUTOSENSE让打印机自己判断输入流格式。自动感应这个功能实测稳定性一般我建议还是明确指定格式。2.2 A4适配、边距与缩放参数的设置细节用上面的代码直接打一张大尺寸图片时你会发现图片被打印机自动裁剪了。原因在于JPS API提交图片时默认不做缩放而是把图片贴在A4纸张的可打印区域上超出部分直接截掉。解决办法有两种第一种是打印前用ImageIO把图片读出来按目标纸张尺寸算出缩放比例重新绘制再输出BufferedImage original ImageIO.read(new File(filePath)); int targetWidth 2480; // A4 300DPI下的像素宽 int targetHeight 3508; // A4 300DPI下的像素高 // 计算等比例缩放 double scale Math.min((double) targetWidth / original.getWidth(), (double) targetHeight / original.getHeight()); int newWidth (int) (original.getWidth() * scale); int newHeight (int) (original.getHeight() * scale); BufferedImage scaled new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB); Graphics2D g2d scaled.createGraphics(); g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); g2d.drawImage(original, 0, 0, newWidth, newHeight, null); g2d.dispose();第二种是利用MediaPrintableArea这个属性控制打印区域再配合PrinterJob的Printable对象做缩放绘制。这种方式更灵活但代码量大一些适合图片和排版耦合的需求。从实际需求出发纯JPG打印我一般推荐第一种把图片缩放到A4区域内然后用ImageIO.write输出到临时文件或直接通过ByteArrayOutputStream输出再交给打印。这套方案对打印机型号不敏感打印效果也稳定。提示如果你打印的是黑白单据记得把缩略图的色彩模式转成TYPE_BYTE_GRAY这样打印速度快很多而且内容更清晰。彩色图片直接打印在黑白打印机上往往发灰对比度很差。3. PDF打印PDFBox渲染成图片再打印3.1 为什么选PDFBox渲染而非直接作业提交PDF打印比JPG复杂过敏。理论上javax.print支持DocFlavor.INPUT_STREAM.PDF但实际调研发现这取决于打印机驱动是否支持PDF流解释器。许多常见打印机尤其是办公用的激光打印机并不支持直接接收PDF数据流打印任务会直接失败或者打出一堆乱码。所以更通用的思路是用PDFBox把PDF每一页渲染成图片然后走javax.print的图片打印链路。这样做有三个好处第一是兼容性只要打印机支持JPG/PNG图片打印就一定能打PDF第二是可控性可以在渲染时设置DPI按需调整清晰度第三是适配性可以把不同尺寸的PDF页面统一缩放到A4或自定义纸张。缺点是渲染成图片后PDF里原本的矢量文字变成了位图放大看会有轻微锯齿而且打印速度比直接打印PDF慢一些。但在能不能打出来面前这个缺点完全可接受。3.2 完整实现代码与参数讲解用PDFBox渲染PDF再打印核心代码分两步渲染和提交。import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.rendering.PDFRenderer; import javax.print.*; import javax.print.attribute.*; import javax.print.attribute.standard.*; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import javax.imageio.ImageIO; public static boolean printPdf(String filePath) throws Exception { // 1. 加载PDF文档 PDDocument document PDDocument.load(new File(filePath)); PDFRenderer renderer new PDFRenderer(document); int pageCount document.getNumberOfPages(); if (pageCount 0) { document.close(); return false; } // 2. 找一台支持图片打印的打印机 DocFlavor flavor DocFlavor.INPUT_STREAM.JPEG; PrintService[] services PrintServiceLookup.lookupPrintServices(flavor, null); if (services.length 0) { document.close(); return false; } PrintService printService services[0]; // 3. 逐页渲染并加入打印队列 for (int i 0; i pageCount; i) { // DPI设成200平衡清晰度和打印速度 BufferedImage image renderer.renderImageWithDPI(i, 200); // 转成JPEG字节流 ByteArrayOutputStream baos new ByteArrayOutputStream(); ImageIO.write(image, jpg, baos); ByteArrayInputStream bais new ByteArrayInputStream(baos.toByteArray()); // 构造文档 Doc doc new SimpleDoc(bais, flavor, null); PrintRequestAttributeSet attrs new HashPrintRequestAttributeSet(); attrs.add(MediaSizeName.ISO_A4); attrs.add(OrientationRequested.PORTRAIT); attrs.add(new Copies(1)); // 创建任务并打印 DocPrintJob job printService.createPrintJob(); job.print(doc, attrs); bais.close(); baos.close(); } document.close(); return true; }这里两个参数值得细说。一个是DPI我实测下来200DPI足够清晰打印速度快文件体积也不会太大300DPI效果更好但每页渲染时间明显增加如果打印多页文档用户体验会差很多。另一个是MediaSizeName.ISO_A4如果你打印的是PDF页面本身是A4比例这个设置就没问题但如果PDF尺寸比较特殊还是建议按PDF页面实际尺寸动态设置纸张。如果想要PDF页面多大就打多大纸可以用PDFBox读取每页的MediaBox尺寸换算成对应的MediaSizeName。复杂一点但可以实现所见即所得。3.3 打印质量与并发性能优化PDF打印除了功能实现还得考虑质量和性能。测试中发现如果PDF里的文字是黑色渲染成JPG后直接打印会有发灰的情况仔细观察是JPEG压缩对文字边缘造成了轻微模糊。解决方式是渲染时把图片格式换成PNG或者把渲染的缩放比例提高到250DPI以上然后用有损JPEG格式压缩。如果你担心渲染出来的文字模糊还可以在渲染后对图片做一次二值化处理把接近黑色的像素全部变成纯黑。这个方法我试过几次效果立竿见影但要注意二值化处理不当会把浅色内容整个抹掉阈值建议选180-200之间。性能上多页PDF逐页创建DocPrintJob没有问题但每次job.print()都会阻塞到该页任务送入打印队列才算完成。如果页数很多建议用线程池控制在3-5个并发避免多个任务同时挤进打印机造成乱页。打印本身就是IO密集型的操作开太多线程没什么收益。4. Word打印四套方案实测最稳的是曲线救国4.1 方案AJACOB调用Word COMWindows专用Word是所有格式里最让人头疼的。根本原因在于Word文档本质上不是一种页面描述语言而是排版文件。Java想直接解析它并还原成打印结果必须依赖POI这类库但POI还原出来的样式Word渲染效果往往差很远。所以第一套方案我试了JACOB——直接在Java里通过COM调用Windows上的Word应用程序让Word自己去打印。import com.jacob.activeX.ActiveXComponent; import com.jacob.com.Dispatch; public static boolean printWordByJacob(String filePath) { ActiveXComponent word null; try { // 创建Word应用实例 word new ActiveXComponent(Word.Application); Dispatch.put(word, Visible, false); Dispatch.put(word, DisplayAlerts, 0); // 禁止弹窗 // 打开文档 Dispatch documents word.getProperty(Documents).toDispatch(); Dispatch doc Dispatch.invoke(documents, Open, Dispatch.Method, new Object[]{filePath, false, true}, new int[1]).toDispatch(); // 打印0代表后台打印true表示保存后续修改 Dispatch.call(doc, PrintOut, 0, false); // 关闭文档并退出Word Dispatch.call(doc, Close, false); return true; } catch (Exception e) { e.printStackTrace(); return false; } finally { if (word ! null) { try { word.invoke(Quit, 0); } catch (Exception ignored) {} } } }这套方案打印出来的效果跟用户在Word里按CtrlP一模一样排版字体分毫不差。但它有几个很不舒服的地方必须装Office且Windows系统并发调用多个Word实例容易资源耗尽如果服务器上Office的许可证激活异常COM会出现各种奇怪错误每次打印时进程启动和销毁还比较慢约2-3秒。实测下来如果打印量不大、环境固定是Windows、且Office正版激活正常可以用它。否则不建议做为主方案。4.2 方案BPOI渲染文本只适合纯文字第二套方案走纯Java路线用POI解析Word内容再把文字丢给Java的Printable接口绘制到打印机。import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.userextractor.XWPFWordExtractor; import java.awt.print.*; import java.awt.*; public static void printWordByPoi(String filePath) throws Exception { XWPFDocument doc new XWPFDocument(new FileInputStream(filePath)); XWPFWordExtractor extractor new XWPFWordExtractor(doc); String text extractor.getText(); doc.close(); PrinterJob job PrinterJob.getPrinterJob(); job.setPrintable(new Printable() { Override public int print(Graphics graphics, PageFormat pageFormat, int pageIndex) throws PrinterException { if (pageIndex 0) return NO_SUCH_PAGE; Graphics2D g2d (Graphics2D) graphics; g2d.translate(pageFormat.getImageableX(), pageFormat.getImageableY()); g2d.setFont(new Font(宋体, Font.PLAIN, 12)); // 模拟绘制文本内容省略分页计算 g2d.drawString(text, 0, 20); return PAGE_EXISTS; } }); job.print(); }写这段代码只用了半小时但效果惨不忍睹图片丢失、表格错位、字体样式和段间距全部丢失一个正常排版的Word文档打印出来就像纯文本记事本。果断放弃POI直打方案。POI的价值更适合用来读取Word里的数据做汇总统计而不是去还原打印排版。除非你的Word文档本身就是纯文字无格式的否则千万别走这条路。4.3 方案CLibreOffice无头转PDF再打印推荐我认为现在最稳、最通用的方案是Word转PDF再打印。具体流程是先在服务器上装一个LibreOffice用命令行无头模式把Word转成PDF然后调用第3章的PDF打印方法。这样一来排版由LibreOffice负责还原打印由PDFBox渲染互不干扰效果经过实测还原度还挺高。先安装LibreOffice然后调用命令行soffice --headless --convert-to pdf --outdir /tmp/output /tmp/input.docx在Java里可以用ProcessBuilder执行命令import java.io.File; import java.io.IOException; public static boolean convertWordToPdfByLibreOffice(String inputPath, String outputDir) { try { ProcessBuilder pb new ProcessBuilder( soffice, --headless, --convert-to, pdf, --outdir, outputDir, inputPath ); pb.redirectErrorStream(true); Process process pb.start(); process.waitFor(); return new File(outputDir, new File(inputPath).getName().replaceAll((?i)\\.(docx|doc)$, .pdf)).exists(); } catch (IOException | InterruptedException e) { e.printStackTrace(); return false; } }注意几个问题soffice命令的路径在Windows和Linux下不一样Windows一般要写完整的C:\Program Files\LibreOffice\program\soffice.exeLinux通常是/usr/bin/soffice执行命令时最好用绝对路径转换大文档时waitFor()会等一段时间建议放到异步线程里执行。LibreOffice转换Word有一个老毛病复杂排版偶尔会有细微偏移比如某个图片位置差几毫米、某些字体替换成替代字体。但绝大多数办公打印场景根本看不出区别比我预想的好很多。而且方案不依赖WindowsLinux服务器上照样跑这让它成了我的第一推荐。提示如果你的Word文档里包含特殊字体记得在服务器上安装对应字体否则LibreOffice会用默认字体替换打印效果会跟本地打开Word差很多。这是最容易忽略的坑。4.4 方案DSpire.Doc商业库含免费版限制第四套方案是使用商业库Spire.Doc。它可以直接加载Word文档并通过Java打印API输出不需要本机装Word或LibreOffice。import com.spire.doc.Document; import com.spire.doc.FileFormat; import com.spire.doc.documents.ImageType; public static void printWordBySpire(String filePath) throws Exception { Document doc new Document(); doc.loadFromFile(filePath); // Spire.Doc 的Java版本可以直接用PrinterJob打印 // 也可以先转成图片再打印 doc.saveToImages(0, ImageType.Bitmap); }这个库功能很强大商业版的价格不算便宜免费版有几个限制最多只能处理500个段落或者25个表格超过的部分不会渲染。如果你的文档比较简单用免费版测试完全够用。但商用项目里我一般不把免费版作为长期依赖一方面有页数和段落数上限另一方面对项目来说引入一个闭源库要有授权评估成本偏高。整体来看Spire.Doc适合那种不太想依赖外部命令、又想保持较高还原度的项目预算充足的话可以买授权。否则还是方案C更实际。4.5 四套方案横向对比与选型建议方案还原度跨平台依赖环境实现成本适用场景JACOB调用Word最高仅Windows必须安装Office低打印量小、Windows固定环境POI直接渲染低跨平台无低仅纯文字内容不推荐LibreOffice转PDF高跨平台需要安装LibreOffice中最推荐通用性强Spire.Doc高跨平台无中免费版有限制预算充足、不想依赖外部命令从综合性价比来看LibreOffice转PDF那个方案是我最常用的。它跨平台、还原度可以接受、不涉及商业授权唯一的要求是服务器上装一个免费开源的LibreOffice这点绝大多数团队都能接受。5. 常见问题与排查实录5.1 打印任务没反应先检查PrintService和Flavor项目联调现场最常遇到的问题就是代码执行成功、没有异常打印机也没动静。绝大多数情况是打印机驱动不支持你提交的文档类型。我用lookupPrintServices(flavor, attrs)过滤后如果返回数组长度为0说明系统里没有符合条件的打印机。排查时先做一个实测直接打印一个JPG图片看打印机是否正常出纸。如果图片能打而PDF不能打问题一定在PDF渲染或flavor设置上。另外Windows的打印到PDF这种虚拟打印机也会出现在PrintService列表里它不能真出纸但能在队列里显示成功别被它迷惑了。5.2 纸张方向错了横向变纵向的修正默认OrientationRequested.PORTRAIT只会影响你设置的方向但如果PDF页面本身是横向的渲染出来的图片也是横向的打印机却按纵向出纸内容就被旋转了90度。解决办法是打印之前读取PDF每一页的getRotation()属性和页面尺寸判断宽高比再决定用OrientationRequested.LANDSCAPE还是PORTRAIT。PDPage page document.getPage(i); int rotation page.getRotation(); PDRectangle mediaBox page.getMediaBox(); boolean isLandscape mediaBox.getWidth() mediaBox.getHeight(); attrs.add(isLandscape ? OrientationRequested.LANDSCAPE : OrientationRequested.PORTRAIT);这个判断要放在循环里面因为一个PDF文档里可能同时存在横向页和纵向页。实测发现有些PDF页面元数据里的宽高跟实际旋转角度是反的最终的兜底办法是判断加旋转后的有效宽高或者干脆两套都试打一次对比。5.3 图片发黑、文字乱码、内容被裁剪图片发黑主要是因为JPEG压缩或灰度模式下对比度过高。解决办法是渲染PNG格式或者在二值化时降低阈值。文字乱码在Word打印方案中出现最多尤其是JACOB方案可能是Word检测到了安全警告弹窗导致DisplayAlerts设置没生效。建议在调用前用Dispatch.put(word, DisplayAlerts, 0)同时在Word的宏安全级别里允许程序调用。LibreOffice方案出现乱码则多半是字体缺失装字体就能解决。内容被裁剪大多数是缩放参数不对。打印JPG时如果图片分辨率小于纸张像素打印机默认按原尺寸打印而原尺寸小于纸张时可能被拉到纸张大小也可能不拉不同驱动表现不一样。统一处理的方式是提前把图片缩放到比目标DPI略大的像素比如200DPI对应约1654x2339像素这样驱动就无从偷懒了。5.4 多任务并发打印时如何控制队列并发打印时大批量任务一起提交打印机处理不过来会导致乱序。DocPrintJob本身是线程安全的但不意味着你该无脑开线程池。我实测过一个线程池提交20个打印任务结果是打印机内部队列混乱最后几页纸交错输出。我的经验是用Semaphore控制同时提交的任务数不超过2个或者干脆串行打印。在Java里可以把所有待打印文件的路径放进一个BlockingQueue用单线程消费者逐个打印代码最简单也完全没有乱序问题。如果确实需要并发可以按打印机分组每台打印机一个队列保证到达同一台打印机的任务是有序的。这套思路跟数据库连接池差不多本质是控制共享资源的并发度。这次做完整套打印功能最大的体会是Java打印的难点从来不在javax.print本身而在怎么把各种文件格式翻译成打印机认识的语言。JPG用原生API、PDF用PDFBox渲染、Word用LibreOffice转PDF再走前面两套链路这套组合拳打下来能覆盖市面上绝大多数打印需求。如果后续项目遇到打印条码、打印标签纸这类需求思路也是一样的——先想清楚怎么转换成打印机认识的格式再考虑打印参数。最后再分享一个小技巧打印功能调试时先在Windows里装一个Microsoft Print to PDF虚拟打印机把要打印的内容先输出成PDF文件看看效果这样能快速定位问题是出在渲染层还是发送层能省下不少调试时间。
阅读完成 · 觉得有帮助?