## 1. 为什么你的PDF打印总是乱序?一个财务同事的烦恼
前几天,财务部的小李又来找我吐槽了。他每个月总有那么几天,要处理上百份报销单据。流程是这样的:从内部系统下载好每一份报销单的PDF文件,然后全部打印出来,再和纸质票据装订在一起。听起来很简单对吧?但问题就出在打印这个环节。
他试过在文件夹里把PDF文件按时间顺序排好,然后全选,右键点击“打印”。也试过一个一个文件打开,手动点击打印按钮。但无论哪种方式,打印机吐出来的文件顺序,永远像开盲盒一样,毫无规律可言。最后他不得不花大量时间,像玩拼图一样,把打印出来的散页和对应的票据重新匹配、排序。用他的话说:“眼睛都快看瞎了,效率低到令人发指。”
这其实不是打印机或者小李的错。当我们向打印机发送多个打印任务时,这些任务会进入一个叫“打印队列”的地方排队。操作系统和打印机驱动在处理这些队列时,会受到很多因素影响,比如文件大小、系统瞬时负载、甚至网络延迟(如果是网络打印机)。这就导致任务完成的顺序,很可能不是我们发送的顺序。对于需要严格顺序的文档,比如合同、标书、带页码的报告,或者小李的报销单,这种无序就是一场灾难。
所以,我们今天要解决的问题非常明确:**如何用Python,让打印机严格地、一个接一个地、按照我们指定的顺序,批量打印PDF文件。** 这个方法尤其适合财务、行政、法务等需要处理大量顺序文档的岗位,能把你从繁琐的手工匹配中彻底解放出来。核心工具就是Windows平台下的`win32print`模块,它让我们能直接和打印机“对话”,精细控制整个打印流程。
## 2. 准备工作:搭建你的Python打印环境
工欲善其事,必先利其器。在开始写代码之前,我们需要先把“厨房”收拾好,把必要的“食材”和“工具”备齐。整个过程很简单,跟着我做就行。
### 2.1 安装核心武器库:pypiwin32
`win32print`模块并不是Python标准库的一部分,它属于一个叫做`pypiwin32`的第三方包。这个包封装了大量用于操作Windows系统的接口,打印只是其功能之一。安装它只需要一行命令。
打开你的命令行工具(CMD或者PowerShell),输入以下命令:
```bash
pip install pypiwin32
```
如果你在使用Anaconda或者某个IDE(比如PyCharm)内置的终端,确保你是在正确的Python环境下执行。安装过程通常很快,看到“Successfully installed”的字样就说明搞定了。我建议在安装后,顺手升级一下pip工具本身,这能避免一些潜在的版本兼容性问题,命令是 `python -m pip install --upgrade pip`。
### 2.2 认识一下我们的“操作台”:win32print与ShellExecute
在动手之前,我们先快速了解一下即将使用的两个核心“工具”是干什么的,这样写代码时心里更有底。
* **win32print**: 你可以把它想象成打印机的“管理后台”。通过它,我们可以查询电脑上安装了哪些打印机、获取默认打印机、打开打印机连接(获取句柄)、查看打印队列里有多少任务、暂停或删除任务等等。它负责的是“状态监控”和“队列管理”。
* **win32api.ShellExecute**: 这个函数更偏向于“执行动作”。它本质上是调用了Windows系统的`ShellExecute` API,可以用于打开文件、运行程序,以及——最重要的——打印文档。当我们把文件路径和“print”指令传给它时,它就相当于帮我们模拟了在文件上右键点击“打印”的操作。它的优势是简单直接,系统会自动调用关联的应用程序(比如Adobe Reader)来执行打印。
简单来说,我们的自动化策略将是:用`ShellExecute`发送打印命令,然后用`win32print`监视打印队列,确保前一个任务彻底完成(队列清空)后,再发送下一个。这样就形成了严格的顺序控制。
## 3. 第一步:与打印机建立连接并获取文件列表
现在,我们正式进入编码环节。第一步,我们要让程序知道它要操作哪台打印机,以及要打印哪些文件。
### 3.1 获取默认打印机并建立连接
在大多数办公场景下,我们直接使用系统设置的默认打印机就够了。`win32print`让这个操作变得极其简单。
```python
import win32print
import win32api
class PDFBatchPrinter:
def __init__(self):
# 获取当前系统的默认打印机名称
self.printer_name = win32print.GetDefaultPrinter()
if not self.printer_name:
print("错误:未找到默认打印机。请在系统设置中配置默认打印机。")
return
print(f"已连接到默认打印机:{self.printer_name}")
# 打开打印机,获取一个“句柄”(可以理解为操作打印机的遥控器)
self.printer_handle = win32print.OpenPrinter(self.printer_name)
def __del__(self):
# 在对象销毁时,记得关闭打印机连接,这是个好习惯
if hasattr(self, 'printer_handle'):
win32print.ClosePrinter(self.printer_handle)
```
这段代码定义了一个打印机的控制器类。在初始化时,它自动抓取系统默认打印机,并建立连接。如果没找到打印机,会给出友好提示。`OpenPrinter`获得的`handle`非常重要,后续查询队列状态全靠它。
### 3.2 让用户优雅地选择要打印的文件夹
我们不可能每次都在代码里硬编码文件路径。一个友好的程序应该弹出一个文件夹选择对话框,让用户自己选。这里我们用Python自带的`tkinter`库来实现,虽然它主要用来做GUI,但用来弹个对话框非常方便。
```python
import os
from pathlib import Path
from tkinter import Tk
from tkinter.filedialog import askdirectory
class PDFBatchPrinter:
# ... 上面的 __init__ 代码 ...
def select_pdf_folder(self):
"""
弹出文件夹选择对话框,并返回该文件夹下所有的PDF文件列表。
使用Path对象,路径处理更现代、安全。
"""
# 创建并隐藏Tkinter主窗口
root = Tk()
root.withdraw() # 隐藏主窗口
root.attributes('-topmost', True) # 让对话框保持在最前
# 弹出选择对话框
folder_path = askdirectory(title="请选择包含PDF文件的文件夹")
if not folder_path: # 用户点了取消
print("未选择文件夹,程序退出。")
return None
print(f"已选择文件夹:{folder_path}")
# 使用Path对象的glob方法,查找所有.pdf文件
source_dir = Path(folder_path)
# 这里使用 `rglob('*.pdf')` 可以包含子目录,如果只需要当前目录,用 `glob('*.pdf')`
pdf_files = list(source_dir.glob('*.pdf'))
if not pdf_files:
print("警告:在所选文件夹中未找到任何PDF文件。")
return None
print(f"找到 {len(pdf_files)} 个PDF文件。")
return pdf_files
```
这个方法会返回一个由`Path`对象组成的列表,每个对象代表一个PDF文件。`Path`是Python 3.4以后推荐的处理路径的方式,比老的`os.path`更直观。
## 4. 核心逻辑:实现严格的顺序打印控制
拿到了打印机和文件列表,接下来就是最核心的部分:如何让它们一个接一个地打印。关键在于对打印队列的监控。
### 4.1 监控打印队列:等待的艺术
直接循环调用`ShellExecute`发送所有文件,结果又会回到无序状态。我们必须等待一个文件完全进入打印机硬件队列(或者完全离开软件队列),再发送下一个。`win32print.EnumJobs`函数可以列出指定打印机的所有任务。
```python
import time
class PDFBatchPrinter:
# ... 之前的代码 ...
def wait_for_printer_idle(self):
"""
等待当前打印机的任务队列清空。
这是一个阻塞函数,会持续检查直到队列为空。
"""
if not hasattr(self, 'printer_handle'):
return
try:
# EnumJobs 参数:句柄,起始任务号,结束任务号,信息级别
# 0, -1, 1 表示获取所有任务的基本信息
jobs = win32print.EnumJobs(self.printer_handle, 0, -1, 1)
while jobs:
job_count = len(jobs)
# 这里可以获取第一个任务的状态信息,让等待更有“温度”
first_job_status = jobs[0]['Status'] if jobs else 0
status_map = {
win32print.JOB_STATUS_PRINTING: "正在打印",
win32print.JOB_STATUS_PAUSED: "已暂停",
win32print.JOB_STATUS_ERROR: "错误",
# ... 其他状态码
}
status_text = status_map.get(first_job_status, "等待中")
print(f"\r打印机队列中尚有 {job_count} 个任务,当前任务状态:{status_text}", end='')
time.sleep(1) # 每秒检查一次,避免过度占用CPU
jobs = win32print.EnumJobs(self.printer_handle, 0, -1, 1)
print("\n打印机队列已清空,准备下一个任务。")
except Exception as e:
# 有时打印机断开或出错,EnumJobs会抛出异常
print(f"\n查询打印队列时发生错误:{e}")
# 这里可以根据实际情况选择是等待、重试还是退出
time.sleep(3) # 简单等待3秒后尝试继续
```
这个`wait_for_printer_idle`方法是实现顺序打印的灵魂。它通过一个`while`循环,不断检查队列,直到队列为空才返回。我加了一些状态提示,让你能在命令行里看到实时进度,而不是干等着。
### 4.2 发送打印命令并控制流程
有了队列监控,发送打印命令就很简单了。我们结合`ShellExecute`和等待函数。
```python
class PDFBatchPrinter:
# ... 之前的代码 ...
def print_single_pdf(self, pdf_path):
"""
打印单个PDF文件,并等待其完成。
"""
pdf_path_str = str(pdf_path)
if not os.path.exists(pdf_path_str):
print(f"文件不存在,跳过:{pdf_path_str}")
return False
print(f"正在发送打印任务:{pdf_path.name}")
# 关键命令:ShellExecute
# 参数说明:
# 0: 父窗口句柄(无)
# "print": 执行的操作是“打印”
# pdf_path_str: 要打印的文件路径
# '/d:"%s"' % self.printer_name: 指定目标打印机,/d是参数
# ".": 工作目录
# 0: 显示方式(0=隐藏)
try:
win32api.ShellExecute(0, "print", pdf_path_str,
f'/d:"{self.printer_name}"', ".", 0)
except Exception as e:
print(f"发送打印命令失败:{e}")
return False
# 短暂等待,确保任务已进入队列
time.sleep(0.5)
# 等待这个任务完成
self.wait_for_printer_idle()
return True
```
这里有几个细节需要注意:
1. `ShellExecute`的第三个参数是文件路径,必须是字符串。
2. 第四个参数 `f'/d:"{self.printer_name}"'` 是指定打印机的关键。`/d`是命令行打印命令的参数,后面接打印机名称。
3. 发送命令后,我故意让程序`sleep(0.5)`秒。这是因为`ShellExecute`是异步的,命令发出后系统需要一点时间来创建打印任务并放入队列。如果没有这个短暂等待,紧接着调用`wait_for_printer_idle`可能会误判队列为空。
## 5. 整合与优化:打造健壮的批量打印程序
我们把前面的所有模块组装起来,并增加一些实用功能,比如文件排序和错误处理,让它成为一个真正可用的工具。
### 5.1 主流程与文件排序
用户可能希望按文件名、修改时间或创建时间来排序打印。我们提供一个选择。
```python
class PDFBatchPrinter:
# ... 之前的代码 ...
def sort_files(self, file_list, sort_by='name', descending=False):
"""
对文件列表进行排序。
:param file_list: Path对象的列表
:param sort_by: 'name'(文件名), 'mtime'(修改时间), 'ctime'(创建时间)
:param descending: True为降序,False为升序
:return: 排序后的列表
"""
if not file_list:
return []
if sort_by == 'name':
sorted_files = sorted(file_list, key=lambda x: x.name.lower(), reverse=descending)
elif sort_by == 'mtime':
sorted_files = sorted(file_list, key=lambda x: x.stat().st_mtime, reverse=descending)
elif sort_by == 'ctime':
sorted_files = sorted(file_list, key=lambda x: x.stat().st_ctime, reverse=descending)
else:
print(f"不支持的排序方式 '{sort_by}',将按文件名排序。")
sorted_files = sorted(file_list, key=lambda x: x.name.lower(), reverse=descending)
# 打印排序后的前几个文件,让用户确认
print("排序后的文件列表(前5个):")
for f in sorted_files[:5]:
print(f" - {f.name}")
if len(sorted_files) > 5:
print(f" ... 以及另外 {len(sorted_files) - 5} 个文件")
return sorted_files
def run(self):
"""主运行流程"""
# 1. 初始化打印机连接
if not hasattr(self, 'printer_handle'):
return
# 2. 选择文件夹
pdf_files = self.select_pdf_folder()
if not pdf_files:
return
# 3. 询问排序方式(这里简化处理,实际可以用argparse或GUI选择)
# 例如,默认按修改时间升序(最早的最先打印)
sorted_files = self.sort_files(pdf_files, sort_by='mtime', descending=False)
# 4. 开始顺序打印
input(f"\n准备开始打印 {len(sorted_files)} 个文件。按回车键继续,或按Ctrl+C取消...")
print("="*50)
success_count = 0
for idx, pdf_file in enumerate(sorted_files, 1):
print(f"\n[{idx}/{len(sorted_files)}] ", end='')
if self.print_single_pdf(pdf_file):
success_count += 1
else:
print(f"文件打印失败:{pdf_file.name}")
# 这里可以增加重试逻辑或记录错误
user_choice = input("是否继续打印下一个文件?(y/n): ")
if user_choice.lower() != 'y':
print("用户中断打印。")
break
print("="*50)
print(f"批量打印完成!成功:{success_count},失败:{len(sorted_files)-success_count}")
if __name__ == '__main__':
printer = PDFBatchPrinter()
printer.run()
```
这个`run`方法串联了整个流程,并加入了简单的交互。`sort_files`函数提供了灵活性,财务同事可能喜欢按修改时间(即下载时间)排序,而行政同事整理文件时可能更倾向于按文件名排序。
### 5.2 错误处理与边界情况考虑
在实际使用中,总会遇到各种意外。一个健壮的程序必须能妥善处理它们。
* **打印机脱机或缺纸**:`win32print.EnumJobs`可能失败,或者任务状态一直卡住。我们可以在`wait_for_printer_idle`中加入超时机制和更详细的状态检查。
* **PDF文件被占用**:如果某个PDF正被其他程序(如阅读器)打开,`ShellExecute`可能会失败。可以在打印前尝试检查文件是否可读。
* **用户取消打印**:在打印过程中,用户可能想中途停止。我们在循环里加入了简单的键盘中断(Ctrl+C)和失败后的交互确认。
* **大量文件打印**:打印几百个文件可能需要很长时间。可以考虑增加一个“暂停/继续”的功能,或者将任务列表保存到文件,以便意外中断后可以恢复。
这里给一个增强版等待函数的例子,加入了超时和错误状态判断:
```python
def wait_for_printer_idle_enhanced(self, timeout_seconds=300):
"""等待打印机空闲,支持超时和错误状态检测"""
start_time = time.time()
while time.time() - start_time < timeout_seconds:
try:
jobs = win32print.EnumJobs(self.printer_handle, 0, -1, 1)
if not jobs:
return True # 队列已空,成功
# 检查第一个任务是否处于错误状态
first_job = jobs[0]
if first_job['Status'] & win32print.JOB_STATUS_ERROR:
print(f"\n打印任务出错(ID: {first_job['JobId']})。请检查打印机状态。")
return False
except Exception as e:
print(f"\n查询队列异常:{e}")
time.sleep(2)
print(".", end='', flush=True) # 打印进度点
print(f"\n错误:等待打印机超时({timeout_seconds}秒)。")
return False
```
## 6. 进阶技巧:双面打印与PDF合并的备选方案
虽然顺序队列打印是主流解决方案,但在某些特定场景下,还有其他思路可以借鉴。
### 6.1 处理双面打印的页码问题
很多办公室打印机默认设置双面打印。如果一个PDF的页数是奇数,双面打印后最后一页的背面会是空白。这通常没问题,但如果你需要装订,或者追求完美,可能希望所有文档都以偶数页结束。这时可以在打印前,用代码为奇数页PDF自动补一页空白页。
你需要安装`PyPDF2`库:`pip install PyPDF2`。
```python
import PyPDF2
from pathlib import Path
def add_blank_page_if_needed(pdf_path):
"""如果PDF页数为奇数,则在末尾添加一页空白页"""
with open(pdf_path, 'rb') as f:
reader = PyPDF2.PdfReader(f)
if len(reader.pages) % 2 == 1: # 奇数页
print(f" 检测到 {pdf_path.name} 为奇数页({len(reader.pages)}页),正在添加空白页...")
writer = PyPDF2.PdfWriter()
# 复制所有原有页面
for page in reader.pages:
writer.add_page(page)
# 添加一个空白页
writer.add_blank_page()
# 写回原文件(或新文件)
output_path = pdf_path.parent / f"temp_{pdf_path.name}"
with open(output_path, 'wb') as out_f:
writer.write(out_f)
return output_path # 返回新文件路径
return pdf_path # 偶数页,返回原路径
```
在`print_single_pdf`函数中,可以在发送打印前调用这个函数处理一下源文件。注意,处理后会生成临时文件,打印完成后最好删除。
### 6.2 简单粗暴的备选方案:合并PDF
如果文件数量不是特别多(比如几十个),且每个文件页数较少,还有一个更“物理”的解决方案:**把所有PDF合并成一个大的PDF文件,然后打印这个合并后的文件**。由于合并过程可以严格控制顺序,打印时自然就是顺序的。这种方法完全规避了打印队列的顺序问题,但缺点是对打印机内存要求较高,且一旦某个页面出错,整个打印任务可能受影响。
```python
def merge_pdfs(file_list, output_filename="merged_for_printing.pdf"):
"""将多个PDF文件合并为一个"""
merger = PyPDF2.PdfMerger()
for pdf_file in file_list:
merger.append(str(pdf_file))
print(f" 已添加:{pdf_file.name}")
merger.write(output_filename)
merger.close()
print(f"\n所有PDF已合并至:{output_filename}")
return output_filename
```
你可以先调用`merge_pdfs`生成一个合并文件,然后只需要用我们的程序(甚至手动)打印这一个文件即可。这个方法特别适合最终需要装订成册的文件。
## 7. 实战演练与避坑指南
理论讲完了,我们来点实际的。假设你现在就要帮小李解决这个月的报销单打印问题。
**操作步骤:**
1. 将上面所有代码块整合到一个Python文件中,比如`pdf_batch_printer.py`。
2. 确保安装了`pypiwin32`和`PyPDF2`(如果要用合并功能)。
3. 将需要打印的所有PDF文件放到一个文件夹里,比如`D:\报销单_202405`。
4. 运行脚本:`python pdf_batch_printer.py`。
5. 程序会弹出文件夹选择框,导航到`D:\报销单_202405`并选择。
6. 程序会显示找到的文件数量和排序后的前几个文件,让你确认。
7. 按回车开始打印。接下来,你就可以泡杯咖啡,看着命令行窗口的进度提示,等待打印机有序地吐出每一份文件。
**我踩过的坑和给你的建议:**
* **权限问题**:在某些公司电脑上,权限管理严格,`ShellExecute`可能会因为权限不足而失败。尝试以管理员身份运行你的Python脚本或IDE。
* **默认打印机不是物理打印机**:检查一下,你的默认打印机是不是“Microsoft Print to PDF”或“OneNote”这类虚拟打印机?如果是,脚本会默默生成一堆PDF文件而不是纸质文件。务必在系统设置里将真实的物理打印机设为默认。
* **杀毒软件干扰**:个别杀毒软件可能会拦截`win32api`的相关调用,误认为是可疑行为。如果脚本无故失败,可以暂时禁用杀毒软件试试,或者将其加入白名单。
* **文件路径含中文或特殊字符**:虽然`Path`和`ShellExecute`对Unicode支持现在很好了,但如果你遇到“找不到文件”的错误,可以尝试将文件路径先转换为短路径(8.3格式),可以使用`win32api.GetShortPathName(long_path)`。
* **打印机驱动问题**:极少数情况下,老旧的打印机驱动可能与`win32print`的交互有问题。确保你的打印机驱动是最新的。
最后,别忘了代码的扩展性。你可以很容易地为这个脚本添加图形界面(用Tkinter或PyQt),添加配置文件来记忆常用的打印机和排序规则,甚至做成一个定时任务,每天自动打印指定文件夹里的新文件。自动化办公的魅力就在于此,一次投入,长期解放双手。希望这个工具能帮你和小李一样,把时间花在更有价值的事情上,而不是和混乱的纸张作斗争。