Python基于wxPython开发一个图片PDF生成器
作者:winfredzhang
前言
本文将详细解析一个基于wxPython开发的图片PDF生成器应用程序。该程序能够批量处理图片,支持旋转、剪切等编辑功能,并按照指定顺序将图片导出为PDF文件。通过本文,你将学习到wxPython GUI开发、PIL图像处理、ReportLab PDF生成等技术要点。
C:\pythoncode\new\pics2pdf.py
项目概述
功能特性
- 图片管理:浏览文件夹并加载所有图片文件
- 图片预览:实时预览选中的图片
- 图片编辑:
- 旋转功能(支持90度左旋/右旋)
- 剪切功能(鼠标拖拽选择区域)
- 重置功能(恢复原始状态)
- 列表管理:在两个列表间移动图片,调整顺序
- PDF导出:按指定顺序将图片导出为PDF文件
技术栈
- wxPython:GUI界面框架
- PIL/Pillow:图像处理库
- ReportLab:PDF生成库
- Python标准库:文件操作、数据结构
代码架构分析
整体结构
程序采用面向对象设计,主要包含两个类:
ImagePDFFrame:主窗口类,负责整体UI和核心功能CropDialog:剪切对话框类,负责图片剪切功能
ImagePDFFrame (主窗口)
├── UI布局
│ ├── 左侧:图片列表 + 选择文件夹按钮
│ ├── 中间:添加/移除按钮
│ ├── 右侧:待导出列表 + 顺序调整按钮
│ └── 预览区:图片显示 + 编辑按钮
├── 数据管理
│ ├── image_folder:当前文件夹路径
│ ├── image_files:图片文件列表
│ ├── image_modifications:图片编辑记录
│ └── current_preview_image:当前预览图片
└── 功能模块
├── 文件加载
├── 图片预览
├── 图片编辑
└── PDF生成
核心代码详解
1. 初始化与UI布局
def __init__(self):
super().__init__(None, title="图片PDF生成器", size=(1400, 800))
self.image_folder = ""
self.image_files = []
self.current_preview_image = None
self.current_preview_filename = None
self.image_modifications = {} # 关键数据结构:存储图片编辑信息
数据结构设计亮点:
image_modifications 字典的设计非常巧妙:
{
'photo1.jpg': {
'rotation': 90, # 旋转角度(0, 90, 180, 270)
'crop': (x1, y1, x2, y2) # 剪切区域坐标
},
'photo2.jpg': {
'rotation': 0,
'crop': None
}
}
这种设计的优势:
- 每张图片的编辑状态独立存储
- 可随时查询和应用编辑
- 支持撤销和重置操作
2. UI布局:BoxSizer的应用
wxPython使用Sizer进行布局管理,代码中使用了wx.BoxSizer:
main_sizer = wx.BoxSizer(wx.HORIZONTAL) # 水平主布局 # 左侧垂直布局 left_sizer = wx.BoxSizer(wx.VERTICAL) left_sizer.Add(btn_select, 0, wx.ALL | wx.EXPAND, 5) left_sizer.Add(self.listbox1, 1, wx.ALL | wx.EXPAND, 5) # 比例为1,自动扩展 main_sizer.Add(left_sizer, 1, wx.EXPAND)
布局参数解析:
proportion(第二个参数):0:固定大小,不随窗口缩放1:按比例分配剩余空间
flag(第三个参数):wx.ALL:四周都有边距wx.EXPAND:填充可用空间
border(第四个参数):边距像素值
3. 文件加载机制
def load_images(self):
self.image_files = []
self.listbox1.Clear()
self.image_modifications = {}
# 支持的图片格式
image_extensions = ('.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff')
try:
for filename in os.listdir(self.image_folder):
if filename.lower().endswith(image_extensions):
self.image_files.append(filename)
self.listbox1.Append(filename)
# 初始化每张图片的编辑状态
self.image_modifications[filename] = {'rotation': 0, 'crop': None}
技术要点:
- 文件过滤:使用
lower()和endswith()实现大小写不敏感的文件扩展名匹配 - 状态初始化:加载图片时同时初始化编辑状态字典
- 异常处理:使用try-except捕获文件操作异常
4. 图片预览:多选ListBox的处理
这是本项目的一个重要技术点:
def on_select_image(self, event):
selections = self.listbox1.GetSelections() # 获取所有选中项
if selections:
# 只预览第一个选中的图片
filename = self.listbox1.GetString(selections[0])
self.show_preview(filename)
为什么要用GetSelections()?
ListBox设置了wx.LB_EXTENDED样式(多选模式):
self.listbox1 = wx.ListBox(panel, style=wx.LB_EXTENDED)
在多选模式下:
- ❌
GetSelection():会抛出断言错误 - ✅
GetSelections():返回所有选中项的索引列表
这是wxPython的一个常见陷阱,必须根据ListBox的模式选择正确的方法。
5. 图片预览实现
def show_preview(self, filename):
try:
image_path = os.path.join(self.image_folder, filename)
img = Image.open(image_path)
# 应用已保存的编辑
if filename in self.image_modifications:
mods = self.image_modifications[filename]
# 应用旋转
if mods['rotation'] != 0:
img = img.rotate(-mods['rotation'], expand=True)
# 应用剪切
if mods['crop']:
img = img.crop(mods['crop'])
self.current_preview_image = img.copy()
self.current_preview_filename = filename
# 缩放适应预览区域
preview_size = (450, 500)
img.thumbnail(preview_size, Image.Resampling.LANCZOS)
# PIL图像转wxImage
width, height = img.size
if img.mode != 'RGB':
img = img.convert('RGB') # 确保RGB模式
wx_image = wx.Image(width, height)
wx_image.SetData(img.tobytes())
# 显示
self.image_preview.SetBitmap(wx_image.ConvertToBitmap())
self.image_preview.Refresh()
关键技术点:
PIL旋转:rotate(-angle, expand=True)
- 负角度:因为PIL的坐标系与界面旋转方向相反
expand=True:自动扩展画布以容纳旋转后的图像
PIL剪切:crop((x1, y1, x2, y2))
- 坐标是左上角(x1, y1)和右下角(x2, y2)
图像模式转换:
if img.mode != 'RGB':
img = img.convert('RGB')
某些图片格式(如PNG的RGBA、灰度图)需要转换为RGB才能在wxPython中显示
PIL与wxPython的桥接:
wx_image = wx.Image(width, height) wx_image.SetData(img.tobytes()) # 字节数据传递 bitmap = wx_image.ConvertToBitmap()
6. 图片旋转功能
def on_rotate_left(self, event):
if self.current_preview_filename:
filename = self.current_preview_filename
# 模360运算保持角度在0-359范围内
self.image_modifications[filename]['rotation'] = \
(self.image_modifications[filename]['rotation'] - 90) % 360
self.show_preview(filename)
def on_rotate_right(self, event):
if self.current_preview_filename:
filename = self.current_preview_filename
self.image_modifications[filename]['rotation'] = \
(self.image_modifications[filename]['rotation'] + 90) % 360
self.show_preview(filename)
设计思路:
- 旋转操作只修改数据,不直接操作图片文件
- 使用模运算
% 360确保角度值规范化 - 立即调用
show_preview()刷新显示
7. 图片剪切功能:自定义对话框
剪切功能的实现较为复杂,单独使用了CropDialog类:
class CropDialog(wx.Dialog):
def __init__(self, parent, image):
super().__init__(parent, title="剪切图片", size=(800, 700))
self.original_image = image.copy()
self.display_image = None
self.crop_box = None
self.start_point = None # 鼠标起始点
self.end_point = None # 鼠标结束点
self.scale_factor = 1.0 # 缩放比例
核心机制:
a) 图像缩放与坐标映射
def prepare_image(self):
canvas_size = (750, 550)
img = self.original_image.copy()
img.thumbnail(canvas_size, Image.Resampling.LANCZOS)
self.display_image = img
# 计算缩放比例,用于坐标转换
self.scale_factor = self.original_image.size[0] / img.size[0]
这个scale_factor非常关键:
- 显示的是缩放后的图像
- 用户在缩放图像上选择区域
- 必须将坐标转换回原图尺寸
b) 鼠标事件处理
def on_mouse_down(self, event):
self.start_point = event.GetPosition()
self.end_point = self.start_point
def on_mouse_move(self, event):
if event.Dragging() and self.start_point:
self.end_point = event.GetPosition()
self.canvas.Refresh() # 触发重绘
def on_mouse_up(self, event):
if self.start_point:
self.end_point = event.GetPosition()
self.canvas.Refresh()
事件流程:
- 按下鼠标 → 记录起始点
- 拖动鼠标 → 更新结束点并刷新界面
- 释放鼠标 → 确定最终选择区域
c) 自定义绘制
def on_paint(self, event):
dc = wx.PaintDC(self.canvas)
if self.display_image:
# 绘制图片
width, height = self.display_image.size
# ... 转换并绘制图片 ...
# 绘制选择框
if self.start_point and self.end_point:
dc.SetPen(wx.Pen(wx.RED, 2))
dc.SetBrush(wx.Brush(wx.Colour(255, 0, 0, 50))) # 半透明红色
x1, y1 = self.start_point
x2, y2 = self.end_point
rect_x = min(x1, x2)
rect_y = min(y1, y2)
rect_w = abs(x2 - x1)
rect_h = abs(y2 - y1)
dc.DrawRectangle(rect_x, rect_y, rect_w, rect_h)
DC(Device Context)绘图:
wx.PaintDC:专门用于EVT_PAINT事件的绘图上下文SetPen():设置线条样式SetBrush():设置填充样式DrawRectangle():绘制矩形
d) 坐标转换
def get_crop_box(self):
if not self.start_point or not self.end_point:
return None
canvas_width, canvas_height = self.canvas.GetSize()
img_width, img_height = self.display_image.size
# 计算图片在canvas中的偏移(居中显示)
offset_x = (canvas_width - img_width) // 2
offset_y = (canvas_height - img_height) // 2
# 转换为图片坐标
x1 = max(0, min(self.start_point[0], self.end_point[0]) - offset_x)
y1 = max(0, min(self.start_point[1], self.end_point[1]) - offset_y)
x2 = min(img_width, max(self.start_point[0], self.end_point[0]) - offset_x)
y2 = min(img_height, max(self.start_point[1], self.end_point[1]) - offset_y)
# 转换回原始图片坐标(考虑缩放)
orig_x1 = int(x1 * self.scale_factor)
orig_y1 = int(y1 * self.scale_factor)
orig_x2 = int(x2 * self.scale_factor)
orig_y2 = int(y2 * self.scale_factor)
# 验证选择区域大小
if orig_x2 - orig_x1 > 10 and orig_y2 - orig_y1 > 10:
return (orig_x1, orig_y1, orig_x2, orig_y2)
return None
坐标转换步骤:
- 减去偏移量:canvas坐标 → 显示图片坐标
- 边界检查:确保坐标在图片范围内
- 缩放转换:显示图片坐标 → 原图坐标
- 大小验证:确保选择区域不是太小
8. 列表管理功能
def on_add_to_list2(self, event):
selections = self.listbox1.GetSelections()
for sel in selections:
filename = self.listbox1.GetString(sel)
# 防止重复添加
if self.listbox2.FindString(filename) == wx.NOT_FOUND:
self.listbox2.Append(filename)
def on_remove_from_list2(self, event):
selections = list(self.listbox2.GetSelections())
selections.reverse() # 从后往前删除,避免索引变化问题
for sel in selections:
self.listbox2.Delete(sel)
删除技巧:
为什么要反向删除?
# 假设选中索引 [1, 3, 5] selections = [1, 3, 5] selections.reverse() # 变成 [5, 3, 1] # 正向删除的问题: Delete(1) # 删除索引1,后面的元素前移 # 现在原来的索引3变成了索引2,但我们要删除索引3 ❌ # 反向删除: Delete(5) # 删除索引5,不影响前面的元素 Delete(3) # 删除索引3,不影响前面的元素 Delete(1) # 删除索引1 ✅
9. 顺序调整
def on_move_up(self, event):
selections = self.listbox2.GetSelections()
if selections and selections[0] > 0:
selection = selections[0]
filename = self.listbox2.GetString(selection)
self.listbox2.Delete(selection)
self.listbox2.Insert(filename, selection - 1)
self.listbox2.SetSelection(selection - 1) # 保持选中状态
实现逻辑:
- 获取当前选中项
- 删除该项
- 在新位置插入
- 重新选中(提供更好的用户体验)
10. PDF生成:核心功能
def create_pdf(self, pdf_path):
try:
c = canvas.Canvas(pdf_path, pagesize=A4)
page_width, page_height = A4
for i in range(self.listbox2.GetCount()):
filename = self.listbox2.GetString(i)
image_path = os.path.join(self.image_folder, filename)
# 加载并应用编辑
img = Image.open(image_path)
if filename in self.image_modifications:
mods = self.image_modifications[filename]
if mods['rotation'] != 0:
img = img.rotate(-mods['rotation'], expand=True)
if mods['crop']:
img = img.crop(mods['crop'])
# 转换为RGB模式
if img.mode != 'RGB':
img = img.convert('RGB')
# 保存到内存缓冲区
img_buffer = io.BytesIO()
img.save(img_buffer, format='JPEG')
img_buffer.seek(0)
img_width, img_height = img.size
# 计算缩放比例,适应A4页面
width_ratio = (page_width - 40) / img_width
height_ratio = (page_height - 40) / img_height
ratio = min(width_ratio, height_ratio) # 保持宽高比
new_width = img_width * ratio
new_height = img_height * ratio
# 居中显示
x = (page_width - new_width) / 2
y = (page_height - new_height) / 2
# 添加到PDF
c.drawImage(ImageReader(img_buffer), x, y,
width=new_width, height=new_height)
# 添加新页(除最后一页)
if i < self.listbox2.GetCount() - 1:
c.showPage()
c.save()
技术亮点:
a) 内存缓冲区
img_buffer = io.BytesIO() img.save(img_buffer, format='JPEG') img_buffer.seek(0)
为什么使用内存缓冲区?
- ✅ 不需要创建临时文件
- ✅ 提高性能
- ✅ 避免磁盘IO开销
b) 宽高比保持
width_ratio = (page_width - 40) / img_width height_ratio = (page_height - 40) / img_height ratio = min(width_ratio, height_ratio) # 选择较小的比例
使用min()确保图片完整显示在页面内,不会被裁剪。
c) 居中对齐
x = (page_width - new_width) / 2 y = (page_height - new_height) / 2
通过计算剩余空间的一半,实现居中效果。
优化建议与扩展思路
1. 性能优化
问题:大量图片加载时可能卡顿
解决方案:
# 使用线程加载图片
import threading
def load_images_async(self):
def load():
# 加载逻辑
wx.CallAfter(self.update_ui) # 线程安全的UI更新
thread = threading.Thread(target=load)
thread.start()
2. 缩略图缓存
优化:
self.thumbnail_cache = {} # 添加缓存字典
def get_thumbnail(self, filename):
if filename not in self.thumbnail_cache:
img = Image.open(...)
img.thumbnail((100, 100))
self.thumbnail_cache[filename] = img
return self.thumbnail_cache[filename]
3. 批量操作
建议:添加批量旋转、批量剪切功能
def on_batch_rotate(self, event):
selections = self.listbox2.GetSelections()
for sel in selections:
filename = self.listbox2.GetString(sel)
self.image_modifications[filename]['rotation'] += 90
4. 撤销/重做功能
实现思路:
class CommandHistory:
def __init__(self):
self.history = []
self.current = -1
def add_command(self, command):
self.history = self.history[:self.current+1]
self.history.append(command)
self.current += 1
def undo(self):
if self.current >= 0:
self.history[self.current].undo()
self.current -= 1
5. 配置保存
建议:保存用户的编辑状态
import json
def save_project(self):
project = {
'folder': self.image_folder,
'modifications': self.image_modifications,
'export_list': [self.listbox2.GetString(i)
for i in range(self.listbox2.GetCount())]
}
with open('project.json', 'w') as f:
json.dump(project, f)
常见问题与解决
Q1: 为什么图片显示不出来?
可能原因:
- 图片模式不是RGB
- 文件路径编码问题
- 图片损坏
解决:
# 始终转换为RGB
if img.mode != 'RGB':
img = img.convert('RGB')
# 处理路径编码
image_path = os.path.join(self.image_folder, filename)
# 在Windows上可能需要:
# image_path = image_path.encode('utf-8').decode('utf-8')
Q2: PDF生成后图片质量下降?
原因:使用JPEG格式压缩
解决:
# 提高JPEG质量 img.save(img_buffer, format='JPEG', quality=95) # 或使用PNG(文件更大) img.save(img_buffer, format='PNG')
Q3: 大图片加载很慢?
解决:使用渐进式加载
# 先显示低分辨率版本 thumbnail = img.copy() thumbnail.thumbnail((200, 200)) # 显示缩略图... # 异步加载完整版 threading.Thread(target=lambda: self.load_full_image(img)).start()
运行结果

以上就是Python基于wxPython开发一个图片PDF生成器的详细内容,更多关于Python wxPython图片PDF生成器的资料请关注脚本之家其它相关文章!
