记录一个PDF盖章工具(PyQt5 + PyMuPDF)

在数字化办公中,经常需要在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页面并处理用户点击。关键点:

  1. 坐标转换:将屏幕点击坐标转换为PDF点坐标(1点=1/72英寸)

  2. 自适应高度:根据PDF页面比例自动调整显示高度

  3. 印章叠加:在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预览区            |
|                |  (带滚动条,支持长页面)      |
+----------------+-------------------------------+
|                |        页面导航条           |
+----------------+-------------------------------+

左侧工具栏包含:

  1. 文件操作区域:打开PDF文件

  2. 印章设置区域:选择印章图片、调整大小

  3. 编辑控制区域:撤销、清空操作

  4. 保存按钮

五、使用步骤

  1. 打开PDF文件:点击"打开PDF"按钮选择文件

  2. 加载印章:点击"选择印章"按钮,支持PNG、JPG等格式

  3. 调整印章大小:通过缩放比例旋钮调整(5%-500%)

  4. 添加印章:在PDF预览区域点击鼠标左键

  5. 编辑操作:可撤销最后一个印章或清空当前页

  6. 保存文件:点击"另存为PDF"保存盖章后的文件

六、技术要点

  1. 坐标系统转换:需要在屏幕像素坐标、PDF点坐标和图像显示坐标之间进行精确转换

  2. 图像处理:使用Pillow处理印章图片的透明通道

  3. 内存管理:及时关闭PDF文档,避免内存泄漏

  4. 用户体验:添加撤销功能、实时预览和错误提示

七、完整代码

以下是完整的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_())

八、扩展功能建议

  1. 批量盖章:支持在多个位置批量添加相同印章

  2. 印章库管理:保存常用的多个印章,方便快速选择

  3. 模板功能:保存常用的盖章位置模板

  4. 文字水印:除了图片印章,支持添加文字水印

  5. 多页操作:支持跨页复制印章位置

九、总结

通过这个项目,我们实现了:

  • 一个完整的桌面GUI应用

  • PDF文件的解析和渲染

  • 精确的坐标定位系统

  • 图像与PDF的合成功能

  • 友好的用户交互界面

这个工具不仅实用,也是学习PyQt5图形界面编程和PDF处理的好例子。

相关推荐
巴拉巴拉~~2 小时前
Flutter 通用下拉选择组件 CommonDropdown:单选 + 搜索 + 自定义样式
开发语言·javascript·ecmascript
ZHang......2 小时前
LeetCode 1114. 按序打印
java·开发语言·算法
Faker66363aaa2 小时前
Arive-Dantu叶片识别系统:基于cascade-mask-rcnn_regnetx-400MF_fpn_ms-3x_coco模型实现_1
python
想你依然心痛2 小时前
AI赋能编程语言挑战赛:从Python到Rust,我用AI大模型重塑开发效率
人工智能·python·rust
quikai19813 小时前
python练习项目
python
缺点内向3 小时前
如何在 C# 中重命名 Excel 工作表并设置标签颜色
开发语言·c#·excel
Можно3 小时前
深入理解 JavaScript 函数:分类、特性与实战应用
开发语言·javascript
淼淼7633 小时前
工厂方法模式
开发语言·c++·windows·qt·工厂方法模式
Hui Baby3 小时前
全局事务入口感知子事务方法-TCC
java·开发语言·数据库