一、项目概述
这个工具的主要功能包括:
- 打开和预览pdf文件
- 加载印章图片(支持png、jpg等格式)
- 在pdf页面上精确点击添加印章
- 调整印章大小
- 撤销操作、清空页面印章
- 保存盖章后的pdf文件
二、环境准备
首先需要安装必要的python库:
pip install pyqt5 pymupdf pillow
注意:pymupdf是fitz模块的库,通过pip install pymupdf安装。
三、核心代码解析
1. pdf预览编辑画布类
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. 印章添加逻辑
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. 滚动预览的修复
原版本存在预览区域底部被裁剪的问题,修复方案:
# 创建滚动区域
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文件
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'])四、界面布局设计
工具界面分为三个主要区域:
+----------------+-------------------------------+ | 左侧工具栏 | pdf预览区 | | | (带滚动条,支持长页面) | +----------------+-------------------------------+ | | 页面导航条 | +----------------+-------------------------------+
左侧工具栏包含:
- 文件操作区域:打开pdf文件
- 印章设置区域:选择印章图片、调整大小
- 编辑控制区域:撤销、清空操作
- 保存按钮
五、使用步骤
- 打开pdf文件:点击"打开pdf"按钮选择文件
- 加载印章:点击"选择印章"按钮,支持png、jpg等格式
- 调整印章大小:通过缩放比例旋钮调整(5%-500%)
- 添加印章:在pdf预览区域点击鼠标左键
- 编辑操作:可撤销最后一个印章或清空当前页
- 保存文件:点击"另存为pdf"保存盖章后的文件
六、技术要点
- 坐标系统转换:需要在屏幕像素坐标、pdf点坐标和图像显示坐标之间进行精确转换
- 图像处理:使用pillow处理印章图片的透明通道
- 内存管理:及时关闭pdf文档,避免内存泄漏
- 用户体验:添加撤销功能、实时预览和错误提示
七、完整代码
以下是完整的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处理的好例子。
到此这篇关于使用python开发一个桌面版pdf盖章工具的文章就介绍到这了,更多相关python pdf盖章工具内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论