在数字化办公中,经常需要在PDF文件上加盖电子印章。今天我将分享一个使用Python开发的桌面版PDF盖章工具,支持可视化操作和精准定位。这个工具基于PyQt5和PyMuPDF库,提供了友好的图形界面。
一、项目概述
这个工具的主要功能包括:
-
打开和预览PDF文件
-
加载印章图片(支持PNG、JPG等格式)
-
在PDF页面上精确点击添加印章
-
调整印章大小
-
撤销操作、清空页面印章
-
保存盖章后的PDF文件
二、环境准备
首先需要安装必要的Python库:
python
pip install PyQt5 PyMuPDF pillow
注意:PyMuPDF是fitz模块的库,通过pip install PyMuPDF安装。
三、核心代码解析
1. PDF预览编辑画布类
python
class PdfEditPreview(QLabel):
# 信号:点击坐标 (x, y)
clicked_pos = pyqtSignal(float, float)
def __init__(self):
super().__init__()
self.setAlignment(Qt.AlignCenter)
self.setStyleSheet('background: #e0e0e0; color: #888;')
self.setText("请打开 PDF 文件")
self.doc = None # PDF文档对象
self.page_index = 0 # 当前页码
self.current_pixmap = None # 当前页面图像
self.stamps_to_draw = [] # 当前页印章列表
这个类继承自QLabel,负责显示PDF页面并处理用户点击。关键点:
-
坐标转换:将屏幕点击坐标转换为PDF点坐标(1点=1/72英寸)
-
自适应高度:根据PDF页面比例自动调整显示高度
-
印章叠加:在PDF页面上绘制已添加的印章
2. 印章添加逻辑
python
def add_stamp(self, pdf_x, pdf_y):
if not self.doc or self.seal_bytes is None:
QMessageBox.warning(self, "提示", "请先加载 PDF 和 印章图片!")
return
scale = self.spin_scale.value() / 100.0
# 计算印章在PDF中的实际尺寸
actual_w = self.seal_w_pt * scale
actual_h = actual_w * self.seal_ratio
stamp_record = {
'page': self.current_page,
'x': pdf_x, # PDF坐标X
'y': pdf_y, # PDF坐标Y
'w_pt': actual_w, # 印章宽度(点)
'h_pt': actual_h, # 印章高度(点)
'img_bytes': self.seal_bytes, # 印章图片原始数据
'pixmap': self.seal_pixmap # 用于显示的QPixmap
}
self.stamps_list.append(stamp_record)
self.refresh_preview()
3. 滚动预览的修复
原版本存在预览区域底部被裁剪的问题,修复方案:
python
# 创建滚动区域
self.scroll_area = QScrollArea()
self.scroll_area.setWidget(self.preview)
self.scroll_area.setWidgetResizable(True) # 关键:让预览组件宽度跟随滚动区
self.scroll_area.setStyleSheet("border: 2px dashed #aaa;")
# 在预览类中实现自适应高度
def adjust_height(self):
"""根据当前控件宽度和图片比例,自动调整控件高度"""
if not self.current_pixmap or self.img_w_raw == 0:
return
# 计算宽高比
ratio = self.img_h_raw / self.img_w_raw
# 目标高度 = 当前宽度 * 比例
target_height = int(self.width() * ratio)
# 设置最小高度,这样ScrollArea就会出现滚动条
if self.minimumHeight() != target_height:
self.setMinimumHeight(target_height)
4. 保存PDF文件
python
def save_pdf(self):
if not self.doc: return
if not self.stamps_list:
QMessageBox.information(self, "提示", "当前没有盖任何章,不需要保存。")
return
save_path, _ = QFileDialog.getSaveFileName(
self, "保存文件",
self.pdf_path.replace(".pdf", "_stamped.pdf"),
"PDF Files (*.pdf)"
)
for stamp in self.stamps_list:
page = self.doc[stamp['page']]
# 计算印章在PDF中的位置
rect_x0 = stamp['x'] - stamp['w_pt'] / 2
rect_y0 = stamp['y'] - stamp['h_pt'] / 2
rect_x1 = stamp['x'] + stamp['w_pt'] / 2
rect_y1 = stamp['y'] + stamp['h_pt'] / 2
rect = fitz.Rect(rect_x0, rect_y0, rect_x1, rect_y1)
# 将印章插入PDF
page.insert_image(rect, stream=stamp['img_bytes'])
四、界面布局设计
工具界面分为三个主要区域:
bash
+----------------+-------------------------------+
| 左侧工具栏 | PDF预览区 |
| | (带滚动条,支持长页面) |
+----------------+-------------------------------+
| | 页面导航条 |
+----------------+-------------------------------+
左侧工具栏包含:
-
文件操作区域:打开PDF文件
-
印章设置区域:选择印章图片、调整大小
-
编辑控制区域:撤销、清空操作
-
保存按钮
五、使用步骤
-
打开PDF文件:点击"打开PDF"按钮选择文件
-
加载印章:点击"选择印章"按钮,支持PNG、JPG等格式
-
调整印章大小:通过缩放比例旋钮调整(5%-500%)
-
添加印章:在PDF预览区域点击鼠标左键
-
编辑操作:可撤销最后一个印章或清空当前页
-
保存文件:点击"另存为PDF"保存盖章后的文件
六、技术要点
-
坐标系统转换:需要在屏幕像素坐标、PDF点坐标和图像显示坐标之间进行精确转换
-
图像处理:使用Pillow处理印章图片的透明通道
-
内存管理:及时关闭PDF文档,避免内存泄漏
-
用户体验:添加撤销功能、实时预览和错误提示
七、完整代码
以下是完整的Python代码:
python
import sys
import os
import fitz # PyMuPDF
from io import BytesIO
from PIL import Image
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QGridLayout, QPushButton, QLabel, QFileDialog, QMessageBox,
QSpinBox, QGroupBox, QLineEdit, QSplitter, QScrollArea
)
from PyQt5.QtCore import Qt, pyqtSignal, QRect
from PyQt5.QtGui import QPixmap, QImage, QPainter, QPen, QColor, QIcon
# ===========================
# PDF 预览/编辑画布
# ===========================
class PdfEditPreview(QLabel):
# 信号:点击坐标 (x, y)
clicked_pos = pyqtSignal(float, float)
def __init__(self):
super().__init__()
self.setAlignment(Qt.AlignCenter)
# 去掉固定的 border 样式,改由 ScrollArea 管理外观,这里只保留背景
self.setStyleSheet('background: #e0e0e0; color: #888;')
self.setText("请打开 PDF 文件")
self.doc = None
self.page_index = 0
self.current_pixmap = None
# 存储当前页面的已盖章列表 (由主窗口传入)
self.stamps_to_draw = []
# 坐标转换参数
self.img_w_raw = 0.0
self.img_h_raw = 0.0
def load_page(self, doc, index):
self.doc = doc
self.page_index = index
self.render()
def render(self):
if not self.doc: return
page = self.doc[self.page_index]
# 获取 120 DPI 的图像用于显示
pix = page.get_pixmap(dpi=120)
self.img_w_raw = pix.width
self.img_h_raw = pix.height
img = QImage(pix.samples, self.img_w_raw, self.img_h_raw, pix.stride, QImage.Format_RGB888)
self.current_pixmap = QPixmap.fromImage(img)
# 加载新图片后,立即调整控件高度以适应比例
self.adjust_height()
self.update()
def adjust_height(self):
"""根据当前控件宽度和图片比例,自动调整控件高度,确保底部不被裁剪"""
if not self.current_pixmap or self.img_w_raw == 0:
return
# 计算宽高比
ratio = self.img_h_raw / self.img_w_raw
# 目标高度 = 当前宽度 * 比例
target_height = int(self.width() * ratio)
# 设置最小高度,这样 ScrollArea 就会出现滚动条
if self.minimumHeight() != target_height:
self.setMinimumHeight(target_height)
def resizeEvent(self, event):
"""当窗口大小改变(宽度改变)时,重新计算高度"""
super().resizeEvent(event)
self.adjust_height()
def set_stamps(self, stamps_list):
"""接收主窗口传来的当前页盖章数据,用于重绘"""
self.stamps_to_draw = stamps_list
self.update()
def mousePressEvent(self, e):
if not self.doc or not self.current_pixmap: return
# 计算图片在 Label 中的显示区域(居中)
scaled_pix = self.current_pixmap.scaledToWidth(self.width(), Qt.SmoothTransformation)
img_x_start = (self.width() - scaled_pix.width()) / 2
img_y_start = (self.height() - scaled_pix.height()) / 2
# 获取相对于图片的点击坐标
click_x = e.x() - img_x_start
click_y = e.y() - img_y_start
# 检查是否点击在图片范围内
if click_x < 0 or click_x > scaled_pix.width() or \
click_y < 0 or click_y > scaled_pix.height():
return
# 转换为 PDF 点坐标 (PDF Points)
pdf_page = self.doc[self.page_index]
scale = pdf_page.rect.width / scaled_pix.width()
pdf_x = click_x * scale
pdf_y = click_y * scale
self.clicked_pos.emit(pdf_x, pdf_y)
def paintEvent(self, e):
super().paintEvent(e) # 绘制背景
if not self.doc or not self.current_pixmap: return
painter = QPainter(self)
painter.setRenderHint(QPainter.SmoothPixmapTransform)
# 1. 绘制 PDF 底图
# 使用 scaledToWidth 确保宽度填满,高度随 resizeEvent 自动调整
scaled_pix = self.current_pixmap.scaledToWidth(self.width(), Qt.SmoothTransformation)
img_x_start = (self.width() - scaled_pix.width()) / 2
img_y_start = (self.height() - scaled_pix.height()) / 2
painter.drawPixmap(int(img_x_start), int(img_y_start), scaled_pix)
# 2. 绘制已确认的印章 (Overlay)
pdf_page = self.doc[self.page_index]
pt_to_px = scaled_pix.width() / pdf_page.rect.width
for stamp in self.stamps_to_draw:
# 尺寸转屏幕像素
w_screen = stamp['w_pt'] * pt_to_px
h_screen = stamp['h_pt'] * pt_to_px
# 中心坐标转屏幕像素
cx_screen = stamp['x'] * pt_to_px
cy_screen = stamp['y'] * pt_to_px
# 计算绘制左上角
draw_x = img_x_start + cx_screen - (w_screen / 2)
draw_y = img_y_start + cy_screen - (h_screen / 2)
target_rect = QRect(int(draw_x), int(draw_y), int(w_screen), int(h_screen))
painter.drawPixmap(target_rect, stamp['pixmap'])
# 绘制一个小红框表示这是后加的章
painter.setPen(QPen(QColor(255, 0, 0, 100), 1, Qt.DashLine))
painter.drawRect(target_rect)
painter.end()
# ===========================
# 主窗口
# ===========================
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
# --- 数据状态 ---
self.doc = None
self.pdf_path = ""
self.current_page = 0
self.stamps_list = []
self.seal_pixmap = None
self.seal_bytes = None
self.seal_w_pt = 0.0
self.seal_ratio = 1.0
self.init_ui()
def init_ui(self):
self.setWindowTitle('PDF 手动盖章编辑器 (滚动预览修复版)')
self.setGeometry(100, 100, 1300, 850)
main_widget = QWidget()
self.setCentralWidget(main_widget)
main_layout = QHBoxLayout(main_widget)
# ===== 左侧:工具栏 (保持不变) =====
tools_panel = QVBoxLayout()
tools_panel.setContentsMargins(0, 0, 10, 0)
gb_file = QGroupBox("1. 文件操作")
v_file = QVBoxLayout(gb_file)
btn_open = QPushButton("📂 打开 PDF")
btn_open.setStyleSheet("padding: 8px; font-weight: bold;")
btn_open.clicked.connect(self.open_pdf)
self.lbl_info = QLabel("未加载文件")
self.lbl_info.setStyleSheet("color: #666; font-size: 11px;")
v_file.addWidget(btn_open)
v_file.addWidget(self.lbl_info)
gb_seal = QGroupBox("2. 印章设置")
v_seal = QVBoxLayout(gb_seal)
self.txt_seal_path = QLineEdit()
self.txt_seal_path.setPlaceholderText("选择印章图片...")
self.txt_seal_path.setReadOnly(True)
btn_sel_seal = QPushButton("🖼️ 选择印章")
btn_sel_seal.clicked.connect(self.select_seal)
self.spin_scale = QSpinBox()
self.spin_scale.setRange(5, 500)
self.spin_scale.setValue(35)
self.spin_scale.setSuffix(" %")
self.spin_scale.valueChanged.connect(self.update_seal_preview_info)
self.lbl_seal_preview = QLabel("印章预览")
self.lbl_seal_preview.setAlignment(Qt.AlignCenter)
self.lbl_seal_preview.setFixedSize(150, 150)
self.lbl_seal_preview.setStyleSheet("border: 1px solid #ddd; background: #fff;")
v_seal.addWidget(btn_sel_seal)
v_seal.addWidget(self.txt_seal_path)
v_seal.addWidget(QLabel("缩放比例:"))
v_seal.addWidget(self.spin_scale)
v_seal.addWidget(self.lbl_seal_preview, 0, Qt.AlignCenter)
gb_action = QGroupBox("3. 编辑控制")
v_action = QVBoxLayout(gb_action)
btn_undo = QPushButton("↩️ 撤销上一个印章")
btn_undo.clicked.connect(self.undo_last_stamp)
btn_clear_page = QPushButton("🗑️ 清空当前页印章")
btn_clear_page.clicked.connect(self.clear_page_stamps)
v_action.addWidget(btn_undo)
v_action.addWidget(btn_clear_page)
btn_save = QPushButton("💾 另存为 PDF")
btn_save.setFixedHeight(50)
btn_save.setStyleSheet("background-color: #28a745; color: white; font-size: 14px; font-weight: bold;")
btn_save.clicked.connect(self.save_pdf)
tools_panel.addWidget(gb_file)
tools_panel.addWidget(gb_seal)
tools_panel.addWidget(gb_action)
tools_panel.addStretch()
tools_panel.addWidget(btn_save)
# ===== 中间:预览区 (修复重点) =====
preview_layout = QVBoxLayout()
self.preview = PdfEditPreview()
self.preview.clicked_pos.connect(self.add_stamp)
# --- 创建滚动区域 ---
self.scroll_area = QScrollArea()
self.scroll_area.setWidget(self.preview)
self.scroll_area.setWidgetResizable(True) # 关键:让预览组件宽度跟随滚动区,但高度由组件自己决定
self.scroll_area.setStyleSheet("border: 2px dashed #aaa;")
# 翻页条
nav_layout = QHBoxLayout()
self.btn_prev = QPushButton("◀ 上一页")
self.btn_next = QPushButton("下一页 ▶")
self.lbl_page = QLabel("0 / 0")
self.btn_prev.clicked.connect(self.prev_page)
self.btn_next.clicked.connect(self.next_page)
nav_layout.addWidget(self.btn_prev)
nav_layout.addWidget(self.lbl_page)
nav_layout.addWidget(self.btn_next)
preview_layout.addWidget(QLabel("<b>🖱️ 操作说明:</b>加载 PDF 和印章后,直接在右侧页面上<b>点击鼠标左键</b>即可盖章。"))
preview_layout.addWidget(self.scroll_area, 1) # 添加滚动区域而不是直接添加 preview
preview_layout.addLayout(nav_layout)
# 组合布局
tools_widget = QWidget()
tools_widget.setLayout(tools_panel)
tools_widget.setFixedWidth(280)
main_layout.addWidget(tools_widget)
main_layout.addLayout(preview_layout)
self.update_ui_state()
# ---------- 逻辑功能 (保持不变) ----------
def open_pdf(self):
path, _ = QFileDialog.getOpenFileName(self, "打开 PDF", "", "PDF Files (*.pdf)")
if not path: return
try:
if self.doc: self.doc.close()
self.doc = fitz.open(path)
self.pdf_path = path
self.current_page = 0
self.stamps_list = []
self.lbl_info.setText(os.path.basename(path))
self.refresh_preview()
self.update_ui_state()
except Exception as e:
QMessageBox.critical(self, "错误", f"无法打开文件:\n{str(e)}")
def select_seal(self):
path, _ = QFileDialog.getOpenFileName(self, "选择印章图片", "", "Images (*.png *.jpg *.jpeg *.bmp)")
if not path: return
self.txt_seal_path.setText(path)
try:
pil_img = Image.open(path)
if pil_img.mode != 'RGBA':
pil_img = pil_img.convert('RGBA')
byte_io = BytesIO()
pil_img.save(byte_io, format='PNG')
self.seal_bytes = byte_io.getvalue()
img_doc = fitz.open("png", self.seal_bytes)
page = img_doc.load_page(0)
self.seal_w_pt = page.rect.width
self.seal_ratio = page.rect.height / page.rect.width
img_doc.close()
self.seal_pixmap = QPixmap(path)
self.lbl_seal_preview.setPixmap(self.seal_pixmap.scaled(
self.lbl_seal_preview.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation
))
self.update_seal_preview_info()
except Exception as e:
QMessageBox.critical(self, "图片错误", f"处理印章图片失败:\n{e}")
def update_seal_preview_info(self):
pass
def add_stamp(self, pdf_x, pdf_y):
if not self.doc or self.seal_bytes is None:
QMessageBox.warning(self, "提示", "请先加载 PDF 和 印章图片!")
return
scale = self.spin_scale.value() / 100.0
actual_w = self.seal_w_pt * scale
actual_h = actual_w * self.seal_ratio
stamp_record = {
'page': self.current_page,
'x': pdf_x,
'y': pdf_y,
'w_pt': actual_w,
'h_pt': actual_h,
'img_bytes': self.seal_bytes,
'pixmap': self.seal_pixmap
}
self.stamps_list.append(stamp_record)
self.refresh_preview()
def undo_last_stamp(self):
if not self.stamps_list: return
removed = self.stamps_list.pop()
if removed['page'] == self.current_page:
self.refresh_preview()
else:
QMessageBox.information(self, "撤销", f"已撤销第 {removed['page']+1} 页上的印章")
def clear_page_stamps(self):
old_len = len(self.stamps_list)
self.stamps_list = [s for s in self.stamps_list if s['page'] != self.current_page]
if len(self.stamps_list) < old_len:
self.refresh_preview()
def refresh_preview(self):
if not self.doc: return
current_page_stamps = [s for s in self.stamps_list if s['page'] == self.current_page]
self.preview.set_stamps(current_page_stamps)
self.preview.load_page(self.doc, self.current_page)
self.lbl_page.setText(f"{self.current_page + 1} / {len(self.doc)}")
self.update_ui_state()
def prev_page(self):
if self.current_page > 0:
self.current_page -= 1
self.refresh_preview()
# 翻页时重置滚动条到顶部
self.scroll_area.verticalScrollBar().setValue(0)
def next_page(self):
if self.doc and self.current_page < len(self.doc) - 1:
self.current_page += 1
self.refresh_preview()
# 翻页时重置滚动条到顶部
self.scroll_area.verticalScrollBar().setValue(0)
def update_ui_state(self):
has_doc = self.doc is not None
self.btn_prev.setEnabled(has_doc and self.current_page > 0)
self.btn_next.setEnabled(has_doc and self.current_page < len(self.doc) - 1)
def save_pdf(self):
if not self.doc: return
if not self.stamps_list:
QMessageBox.information(self, "提示", "当前没有盖任何章,不需要保存。")
return
save_path, _ = QFileDialog.getSaveFileName(self, "保存文件", self.pdf_path.replace(".pdf", "_stamped.pdf"), "PDF Files (*.pdf)")
if not save_path: return
try:
for stamp in self.stamps_list:
page = self.doc[stamp['page']]
rect_x0 = stamp['x'] - stamp['w_pt'] / 2
rect_y0 = stamp['y'] - stamp['h_pt'] / 2
rect_x1 = stamp['x'] + stamp['w_pt'] / 2
rect_y1 = stamp['y'] + stamp['h_pt'] / 2
rect = fitz.Rect(rect_x0, rect_y0, rect_x1, rect_y1)
page.insert_image(rect, stream=stamp['img_bytes'])
self.doc.save(save_path)
QMessageBox.information(self, "成功", f"文件已保存至:\n{save_path}")
self.doc.close()
self.doc = fitz.open(save_path)
self.pdf_path = save_path
self.stamps_list = []
self.refresh_preview()
except Exception as e:
QMessageBox.critical(self, "保存失败", str(e))
if __name__ == '__main__':
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling)
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps)
app = QApplication(sys.argv)
w = MainWindow()
w.show()
sys.exit(app.exec_())
八、扩展功能建议
-
批量盖章:支持在多个位置批量添加相同印章
-
印章库管理:保存常用的多个印章,方便快速选择
-
模板功能:保存常用的盖章位置模板
-
文字水印:除了图片印章,支持添加文字水印
-
多页操作:支持跨页复制印章位置
九、总结
通过这个项目,我们实现了:
-
一个完整的桌面GUI应用
-
PDF文件的解析和渲染
-
精确的坐标定位系统
-
图像与PDF的合成功能
-
友好的用户交互界面
这个工具不仅实用,也是学习PyQt5图形界面编程和PDF处理的好例子。