#创作灵感#
PDF在日常办公、学术交流、电子出版等领域占据着举足轻重的地位。然而,与 Word 等富文本编辑器不同,PDF 被设计为"固定布局"格式,其初衷是确保文档在不同设备、不同操作系统上呈现出一致的外观。正因如此,PDF 的编辑一直是一个技术难题。市面上虽然有许多商业 PDF 编辑器(如 Adobe Acrobat、Foxit Phantom),但它们往往价格昂贵,或者功能受限。开源社区的 PDF 编辑工具虽然众多,但要么功能单一(如仅支持合并/拆分),要么界面简陋,难以满足日常需求。
作为一名技术爱好者和工具控,我决定利用 Python 和 Qt 框架,从零开始打造一款功能完备、界面精美的 PDF 编辑器。这不仅是为了满足自己的需求,更是希望通过这个项目,探索如何将多种底层库(PDF 渲染、文档操作、图像处理、自然语言处理等)整合到一个统一的用户界面中,同时保持代码的可扩展性和良好的用户体验。
经过数个版本的迭代,我最终完成了一个名为"PDF智编"的工具。主要的以下几个方面进行了实现:
多标签页管理:同时打开多个 PDF,切换自如。
灵活的页面管理:支持删除单页、连续页、混合页,插入空白页,移动单页或连续页,合并多个文件。
内容提取与编辑:提取文字、图片(可缩放/旋转/裁剪)、公式(LaTeX 渲染),并在界面中直接修改后重新生成页面。
新建与插入:可以新建空白文档(含空白页),在任意位置插入空白页,并在空白页上自由添加文字、图片、公式。
PDF 转 Word:一键导出为可编辑的 Word 文档,保留文字和图片。
翻译与标注:对选定文字进行多语言翻译,结果可保存;支持对关键词进行颜色可调的高亮标注。
保存与另存为:支持增量保存和另存为新文件。
尽管由于 PDF 格式的固有限制,对已有内容的"直接编辑"采用了"提取-修改-重绘"的方式,会牺牲原有排版,但这一策略在开源方案中已是较为可行的折中。我将在文章末尾给出完整的代码,有其它需求或者功能添加的\项目开发的可以在后台私信我。
第一章:技术选型与架构设计
1.1 为什么选择 PyQt5?
Qt 是一个跨平台的 C++ 应用程序框架,以其强大的 GUI 组件、信号槽机制、跨平台能力和丰富的社区资源而闻名。PyQt5 是 Qt 的 Python 绑定,它允许我们使用 Python 语言快速开发 Qt 应用程序,同时保留了 Qt 的核心优势。选择 PyQt5 的原因主要有:
Python 生态丰富 :Python 拥有海量的第三方库,如 PDF 处理库
PyMuPDF、pdf2docx,图像处理库Pillow,机器翻译库googletrans,科学计算库matplotlib等,可以轻松集成到 PyQt 应用中。开发效率高:Python 的简洁语法和 Qt 的 Designer 工具可以大幅缩短开发周期。
跨平台:一次编写,可在 Windows、macOS、Linux 上运行(通过 PyInstaller 打包)。
社区活跃:遇到问题容易找到解决方案。
1.2 PDF 处理库的选择
在 PDF 处理的生态中,有几个知名的开源库:
PyMuPDF (fitz):基于 MuPDF,渲染速度快,支持页面操作(删除、插入、合并)、文本提取、图片提取、注释添加等,是目前 Python 中功能最全面的 PDF 库之一。
pdf2docx:专门用于将 PDF 转换为 Word,基于 Microsoft Word 的解析引擎,对文字和图片的转换效果较好。
PyPDF2 / pdfrw:功能较基础,主要处理页面级别的合并、拆分,不支持内容编辑和渲染。
ReportLab:主要用于生成 PDF,不适合编辑。
综合考量,我们选择 PyMuPDF 作为核心 PDF 操作引擎,因为它既能完成页面级的操作(删除、插入、移动、合并),又能提取文本和图片,还能添加高亮注释和矩形注释,基本满足我们的需求。对于 PDF 转 Word 功能,我们单独引入 pdf2docx。
1.3 其他依赖库
Pillow:处理图片的缩放、旋转、格式转换,以及在 PyQt 和 PIL 之间互相转换。
googletrans:调用 Google 翻译 API 实现文本翻译(需联网)。
matplotlib:用于渲染 LaTeX 公式为图片,然后插入 PDF。
PyInstaller:用于打包成可执行文件。
Inno Setup:用于制作 Windows 安装包。
1.4 环境创建与安装
bash
# 创建一个名为PdfTool的环境,python版本号为3.10
conda create -n PdfTool python=3.10
# pip普通安装,较慢
pip install PyQt5 PyMuPDF pdf2docx googletrans==4.0.0-rc1 pillow matplotlib
# pip清华源安装,较快
pip install PyQt5 PyMuPDF pdf2docx googletrans==4.0.0-rc1 pillow matplotlib -i https://pypi.tuna.tsinghua.edu.cn/simple
# pip读取文件安装
pip install -r requirement.txt
1.5 整体架构
整个编辑器采用 MVC(Model-View-Controller) 的变体,但由于 Qt 本身提供了信号槽机制,我们不必严格遵循,而是将逻辑分离为以下几个部分:
MainWindow :主窗口,负责菜单、工具栏、停靠窗口、标签页容器(
QTabWidget)的管理,以及全局操作(打开/保存/合并/导出等)的调度。PdfTab :每个标签页对应一个打开的 PDF 文档,包含文档对象、当前页码、导航控件、页面视图(
PdfViewer)等,并实现页面级操作(删除、插入、移动、内容编辑)。PdfViewer :自定义的
QGraphicsView,负责渲染和显示单页 PDF,支持缩放、拖拽、适应页面。PageContentEditor:一个对话框,用于编辑页面内容(文字、图片、公式),提供富文本编辑、图片列表、公式输入等功能,最终生成新的页面内容。
ImageEditorDialog:图片编辑对话框,提供缩放、旋转、重置功能,并返回编辑后的图片。
其他辅助类(如
parse_page_range工具函数,render_latex_to_pixmap等)。
数据流方向:用户在 MainWindow 中触发操作(如删除页面),MainWindow 获取当前 PdfTab 实例,调用其方法;PdfTab 操作底层的 fitz.Document 对象,更新内部状态,并刷新 PdfViewer 的显示。缩略图停靠窗口则监听标签切换事件,动态更新当前标签的缩略图列表。
第二章:界面设计与用户体验优化
2.1 布局规划
一个优秀的桌面应用应当布局合理、信息层级清晰。我们的编辑器遵循了经典 IDE 风格:
中央区域 :
QTabWidget承载多个文档标签,每个标签内包含导航工具栏和 PDF 视图。左侧停靠窗口:页面缩略图,方便用户快速跳转。
右侧停靠窗口:属性面板,用于设置字体、字号、颜色等(目前主要用于演示,实际编辑时通过对话框完成)。
顶部菜单栏:包含"文件"、"编辑"、"工具"、"帮助"四大菜单。
顶部工具栏:将最常用的操作(打开、新建、保存、删除、插入、缩放、适应页面、工具选择)以图标按钮形式提供。
底部状态栏:显示当前操作反馈。
整体的界面效果如下所示:

2.2 颜色与字体
为了营造生动活泼的视觉感受,我们使用了 蓝白渐变色 作为主色调,搭配深蓝文字和灰色边框,整体风格清新、现代。具体样式通过 QSS(Qt Style Sheets)实现,部分关键样式如下:
主窗口背景:从上到下的浅蓝渐变(
#e8f0fe→#d9e2f0)。菜单栏:白色背景,悬停时淡蓝高亮。
标签页:未选中时为浅灰蓝,选中时白色并带蓝色下划线。
按钮:从白到灰蓝渐变,悬停时加深边框和背景。
输入框:白色背景,聚焦时出现蓝色外发光边框。
滚动条:圆角设计,宽度适中。
此外,我们统一设置了全局字体为 "Microsoft YaHei",字号 9,确保中英文混排美观。

2.3 图标与交互反馈
Qt 内置了大量的标准图标(通过 QStyle.StandardPixmap),我们尽可能使用这些图标,以减少外部资源依赖。对于菜单和工具栏中的动作,我们分别设置了图标,例如:
"打开"使用
SP_DialogOpenButton"保存"使用
SP_DialogSaveButton"删除"使用
SP_TrashIcon"放大/缩小"使用
SP_ArrowUp/ArrowDown"适应页面"使用
SP_FileDialogDetailedView
在交互反馈方面,我们充分利用了 Qt 的悬浮(hover)和按下(pressed)状态样式,让用户清晰地感知当前可操作元素。同时,状态栏会显示每一步操作的文字提示(如"已删除 3 页"),并配以表情符号(如 🗑️、✏️)增强亲和力。
2.4 缩略图实现
缩略图停靠窗口中的每个条目实际上是一个 QListWidgetItem,其图标为对应页面的缩略图(72 DPI 渲染),文字为页码。用户点击缩略图即可跳转到对应页面,实现快速导航。为了提升性能,缩略图仅在标签切换或页面变化时重新生成,不会频繁刷新。

2.5 响应式缩放与拖拽
PdfViewer 继承自 QGraphicsView,通过重载 wheelEvent 支持按住 Ctrl 滚动滚轮进行缩放(以鼠标位置为中心)。同时,setDragMode(QGraphicsView.ScrollHandDrag) 允许用户按住鼠标拖拽平移视图,方便查看大图。此外,"适应页面"按钮会将视图重置为刚好显示完整页面。
第三章:多标签页管理
3.1 标签页容器
我们使用 QTabWidget 作为中央部件的容器,每个标签页是一个 PdfTab 实例。PdfTab 继承自 QWidget,内部包含一个垂直布局,布局中从上到下依次是导航工具栏、PDF 视图和状态标签。
当用户通过"打开"或"新建"创建文档时,MainWindow 会创建一个 PdfTab 对象,并将它添加到 QTabWidget 中,同时设置标签标题为文件名或"未命名"。标签页右上角的关闭按钮(tabsClosable=True)允许用户关闭标签,关闭前会检查文档是否已保存。
3.2 文档对象的生命周期
每个 PdfTab 持有一个 fitz.Document 对象,在初始化时通过 fitz.open(file_path) 打开。如果用户新建空白文档,则调用 fitz.open() 创建一个空文档,并立即添加一个空白页(new_page)。文档的关闭在标签关闭时通过 close_doc() 方法完成,确保资源释放。
3.3 导航控件
导航工具栏包含"上一页""下一页"按钮、页码输入框(QSpinBox)和总页数标签。用户可以通过点击按钮或直接输入页码来切换页面。每次页面切换时,会调用 update_page() 方法重新渲染当前页的图片,并更新页码输入框的值和总页数标签。
3.4 标签切换与缩略图同步
当用户在不同标签间切换时,QTabWidget 的 currentChanged 信号会触发 on_tab_changed 槽函数,该函数获取当前标签并调用 update_thumbnails 更新左侧缩略图列表。这样,缩略图始终与当前激活的文档对应。
第四章:页面级操作实现
4.1 页面删除
页面删除是用户最常使用的操作之一。我们的"删除页面"功能通过一个对话框让用户输入页码或页码范围,支持以下格式:
单页:
5连续页:
2-5混合:
1,3-5,7
输入的字符串由 parse_page_range 函数解析,返回一个 0-based 的索引列表(已排序)。然后 PdfTab.delete_pages 方法将这些索引从文档中删除(注意要从大到小删除,避免索引变化)。删除后,更新当前页码(若删除后无页则设为 0),并将文档标记为"未保存"。
4.2 插入空白页
用户可以在任意位置插入空白页。通过 QInputDialog.getInt 获取插入位置(页码,1-based),然后将位置减 1 作为 fitz.Document.new_page(position) 的参数。插入后,文档页数增加,当前页设置为插入页,并刷新显示。

4.3 页面移动
页面移动支持单页移动和多页连续移动。用户需要输入起始页码、结束页码和目标位置(均为 1-based),然后调用 move_pages_range 方法。该方法内部利用 fitz.Document.move_page 方法(如果版本支持),否则采用"复制后删除"的兼容方式。移动后更新当前页为移动后的位置。
4.4 合并文件
合并文件功能通过 merge_files 实现。用户选择多个 PDF 文件(按顺序),然后指定保存路径。程序会创建一个新的 fitz.Document,依次将每个源文件的所有页面插入(使用 insert_pdf 方法),最后保存并自动打开合并后的文件作为新标签。
第五章:内容提取与编辑------突破 PDF 的编辑限制
5.1 提取文字、图片、公式
在"编辑页面内容"对话框中,我们提供了三个标签页:
文本 :使用
QTextEdit显示当前页的纯文本(通过page.get_text()提取),用户可以在其中修改文字和格式(支持富文本)。图片 :显示该页提取的所有图片(通过
page.get_images()和fitz.Pixmap保存为临时 PNG 文件),并以缩略图列表展示。用户虽然无法在当前版本中直接对图片进行位置调整或裁剪,但可以通过双击(待实现)打开ImageEditorDialog进行缩放和旋转(此功能已实现但尚未集成到主流程,可作为扩展点)。公式 :由于 PDF 中的公式往往以图片或特殊字体呈现,自动识别为 LaTeX 需要 OCR 技术,这超出了本文范围。因此我们仅提供一个文本输入框,让用户手动输入 LaTeX 代码(如
E = mc^2),然后利用matplotlib渲染为图片。
编辑完成后,点击"确定",程序会调用 replace_page_content 方法,该方法会:
删除原页面。
在相同位置创建一个新页面(尺寸相同)。
将编辑后的文本(转成纯文本,因为插入文本时只支持纯文本,若需要富文本则需使用
insert_html或逐字符设置格式,这里简化处理)插入到页面左上角(偏移 36 点)。将编辑后的图片列表中的每张图片按固定布局(网格)插入到页面中(位置硬编码为两列,每行两图)。
将公式渲染为图片,并插入到页面下方。
这种"重绘"方式确实破坏了原有的排版,但它允许用户实质性地修改内容,对于某些场景(如填写表单、修改错误文本)是实用的。更高级的编辑(如保持原有布局)需要解析 PDF 的内容流并重写,复杂度极高,在开源方案中极少实现。
5.2 新建空白页并自由编辑
对于新建的空白文档或插入的空白页,用户同样可以使用"编辑页面内容"来添加文字、图片和公式。由于页面本身无内容,重绘后呈现的就是用户想要的内容,这实际上相当于一个简易的 PDF 制作工具。
5.3 图片编辑器的实现
我们实现了一个独立的 ImageEditorDialog,它包含一个 QGraphicsView 显示图片,并提供"旋转90°""放大""缩小""重置"按钮。用户调整满意后点击"确定",即可获得编辑后的 QPixmap。虽然该对话框尚未与主编辑流程完全集成(作为示例),但其代码完整,可以轻松嵌入。
5.4 背景色设置
用户可以为指定页面设置背景色。实现原理是在页面上添加一个覆盖整个页面的矩形注释,并设置填充颜色。这不会删除原有内容,而是作为背景层,效果类似批注。
第六章:辅助功能实现
6.1 PDF 转 Word
导出 Word 功能使用了 pdf2docx 库,只需传入源 PDF 路径和目标 docx 路径,调用 Converter.convert() 即可。该库能够较好地保留文字和图片,但复杂排版(如表格、多列)可能丢失,这属于库本身的限制。
6.2 翻译功能
翻译功能基于 googletrans 库(实际上是谷歌翻译的非官方 API)。用户输入待翻译文本,选择目标语言(中、英、日、韩、法、德),程序调用 API 获取翻译结果,并弹出消息框显示。用户可以选择保存结果为文本文件。
6.3 标注高亮
高亮标注允许用户指定页码和关键词,程序使用 page.search_for() 查找所有匹配的文本区域,为每个区域添加高亮注释(add_highlight_annot),颜色可由用户通过颜色对话框选择。高亮后文档会标记为未保存。
第七章:界面美化与用户体验细节
7.1 QSS 样式表详解
我们将所有样式集中定义在 STYLE_SHEET 字符串中,在 MainWindow 初始化时通过 self.setStyleSheet(STYLE_SHEET) 应用。这样做的好处是样式统一,修改方便。以下是一些关键控件的样式设计思路:
QMainWindow:使用渐变背景,避免单调。
QTabWidget:标签页的圆角、边框、选中时的下划线,提升现代感。
QDockWidget:标题栏渐变,使停靠窗口更具层次。
QPushButton:渐变背景、圆角、悬停和按下状态的变化,增强交互反馈。
QListWidget:缩略图条目在选中时具有蓝色边框,方便识别。
QScrollBar:自定义滚动条宽度和颜色,使之与整体风格协调。
7.2 图标与表情符号
Qt 的 QStyle.StandardPixmap 提供了许多常用图标,我们尽量使用这些内置图标,避免额外图片资源。同时,在菜单项和状态栏消息中,我们适度加入表情符号(如 🗑️、✏️、📄、💾、🛠️),使界面更活泼,也直观地表达了操作意图。
7.3 用户反馈机制
状态栏是用户获得反馈的主要渠道。每次操作后,我们都会调用 self.statusBar().showMessage() 显示简短提示,如"✅ 已保存 xxx.pdf"。对于耗时操作(如 PDF 转 Word),我们会在界面上弹出消息框提示完成。
7.4 未保存提醒
当用户尝试关闭含有未保存修改的标签或退出程序时,会弹出询问对话框,提醒用户保存、放弃或取消操作。这防止了数据丢失,是专业软件应有的行为。
第八章:代码结构深度剖析
8.1 主程序入口
if __name__ == "__main__" 块中创建 QApplication,设置全局字体和样式,然后实例化 MainWindow 并显示。这是标准的 PyQt 启动流程。
8.2 MainWindow 类
该类是控制中心,负责:
创建菜单栏、工具栏、停靠窗口。
管理标签页的打开、关闭、切换。
响应所有全局操作(如合并、导出、翻译等)。
维护设置文件的加载与保存(虽然本例未大量使用,但预留了接口)。
关键方法:
open_files():调用文件对话框,对每个选中的文件调用add_tab。
new_blank_doc():创建空白标签。
add_tab(file_path):新建PdfTab并添加到QTabWidget。
close_tab(index):检查未保存,关闭文档并移除标签。
save_current()/save_as_current():保存或另存为。
delete_pages_dialog()、insert_blank_dialog()、move_pages_dialog():弹出相应对话框,并调用当前标签的方法。
edit_page_content():弹出页面内容编辑器,处理编辑后的结果。
merge_files()、export_word()、translate_dialog()、highlight_dialog()等辅助功能。
8.3 PdfTab 类
每个标签页的核心逻辑。重要属性和方法:
doc:fitz.Document对象。
current_page:当前显示页码(0-based)。
total_pages:总页数。
unsaved:修改标记。
update_page():渲染当前页并更新视图。
delete_pages(indices)、insert_blank_page(position)、move_page、move_pages_range:页面操作。
replace_page_content(page_idx, html_text, image_paths, formula_latex_list):用新内容替换指定页。
set_page_background(page_idx, color_rgb):设置背景色。
highlight_text(page_idx, search_text, color):高亮文字。
extract_text()、extract_images(temp_dir):内容提取。
export_word(output_path):导出为 Word。
save()、save_as(new_path)、close_doc():保存和关闭。
8.4 PdfViewer 类
继承 QGraphicsView,负责显示 PDF 页面的图像。核心方法:
display_page(pixmap):清空场景并添加新的QGraphicsPixmapItem,然后调用fitInView适应显示。
wheelEvent:按 Ctrl 键时缩放。
8.5 PageContentEditor 类
编辑页面内容的对话框,包含:
text_edit:QTextEdit支持富文本编辑。
img_list:QListWidget显示图片缩略图。
formula_edit:QTextEdit用于输入 LaTeX。
set_text(text)、get_text()、add_image_pixmap()、get_edited_images()、get_formula_latex()等方法用于数据交换。
8.6 ImageEditorDialog 类
图片编辑对话框,实现旋转、缩放、重置,并返回编辑后的 QPixmap。
8.7 工具函数
parse_page_range(text, max_page):解析用户输入的页码字符串,返回索引列表。
pil_to_pixmap(pil_img):PIL Image 转换为 QPixmap。
render_latex_to_pixmap(latex_str, fontsize, dpi):使用 matplotlib 渲染 LaTeX 为图片。
第九章:测试与打包发布
9.1 功能测试
我们对每个功能点进行了手工测试,涵盖正常流程和边界情况,例如:
打开多个文件,关闭其中某些,检查内存释放。
输入各种页码格式进行删除,确保索引正确。
插入空白页到开头、中间、末尾。
移动单页到不同位置。
编辑页面内容,插入文字、多张图片、多个公式,观察新页面的布局。
设置背景色,检查是否覆盖整页。
高亮文本,检查注释是否正确添加。
翻译功能在不同语言间切换。
导出 Word,对比原 PDF。
保存/另存为,验证文件大小和内容。
9.2 性能优化
渲染页面时使用 2.0 倍放大矩阵(150 DPI),平衡清晰度和性能。
缩略图使用 72 DPI,减少内存占用。
图片提取时只提取像素数小于 4 的(非 CMYK 等特殊色彩空间),避免处理复杂图片。
使用
garbage=4和deflate=True保存时压缩文件。
9.3 打包成可执行文件
使用 PyInstaller 将 Python 脚本打包成单个 EXE 文件(Windows)。编写 .spec 文件指定隐藏导入(fitz, pdf2docx, googletrans, PIL)和图标。由于依赖较多,打包后的 EXE 约 80MB,但可独立运行,无需安装 Python 环境。
9.4 制作安装程序
使用 Inno Setup 创建安装包,将 EXE 文件和必要的资源放入程序目录,添加桌面快捷方式和开始菜单项,并提供卸载功能。
9.5 跨平台考虑
虽然我们的打包侧重 Windows,但由于代码基于 Python 和 Qt,理论上可以在 Linux 和 macOS 上运行,只需相应调整打包方式(如使用 py2app 或 linuxdeploy)。
第十章:经验总结与未来展望
10.1 项目经验
合理选择第三方库:PyMuPDF 功能强大,但文档略显稀疏,需要大量查阅社区示例。pdf2docx 转换质量尚可,但对复杂文档支持有限。
界面与逻辑分离:通过将 UI 操作集中在 MainWindow,逻辑封装在 PdfTab,大大提高了代码的可维护性。
注重用户体验:及时的反馈、美观的界面、流畅的交互是吸引用户的关键。
处理异常情况:例如打开损坏的 PDF 文件、用户取消操作、网络超时等,都需要优雅地处理。
10.2 待改进之处
更智能的内容编辑:目前的重绘方式会丢失原排版。未来可尝试解析 PDF 的内容流,提取每个元素的坐标和属性,然后在编辑后按原坐标重排,但这需要深入 PDF 规范,工作量巨大。
公式自动识别:集成 OCR 引擎(如 Tesseract + pix2tex)实现从图片到 LaTeX 的自动转换。
批量处理:支持批量执行相同操作(如批量删除封面页、批量加水印)。
撤销/重做:实现命令历史,使用户敢于尝试。
更多导出格式:如 HTML、EPUB 等。
10.3 最后一点想说的
当前的实现仍有诸多限制,但它已经能够满足许多日常需求,并且提供了一个可扩展的平台。我希望这篇文章能够激发读者对桌面应用开发和 PDF 处理技术的兴趣,也欢迎各位技术同人提出宝贵意见,共同完善这个工具。
附录 A:完整代码
python
# -*- coding: utf-8 -*-
import sys
import os
import re
import json
import shutil
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
import fitz
from pdf2docx import Converter
from googletrans import Translator
from PIL import Image, ImageQt
import io
import numpy as np
try:
import matplotlib
matplotlib.use('Qt5Agg')
from matplotlib.figure import Figure
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
MATPLOTLIB_AVAILABLE = True
except ImportError:
MATPLOTLIB_AVAILABLE = False
APP_NAME = "PDF 智编"
VERSION = "3.0"
SETTINGS_FILE = "pdf_editor_settings.json"
STYLE_SHEET = """
QMainWindow {
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
stop:0 #e8f0fe, stop:1 #d9e2f0);
}
QMenuBar {
background-color: #ffffff;
border-bottom: 1px solid #c0cbd6;
padding: 4px;
}
QMenuBar::item {
background: transparent;
padding: 6px 12px;
border-radius: 4px;
}
QMenuBar::item:selected { background: #d0e0ff; }
QMenu {
background-color: #ffffff;
border: 1px solid #c0cbd6;
border-radius: 6px;
padding: 4px;
}
QMenu::item {
padding: 6px 24px 6px 12px;
border-radius: 4px;
}
QMenu::item:selected { background: #d0e0ff; }
QToolBar {
background: #ffffff;
border: none;
spacing: 4px;
padding: 6px;
}
QToolBar QToolButton {
background: transparent;
border: none;
border-radius: 6px;
padding: 6px;
}
QToolBar QToolButton:hover { background: #e0edff; }
QToolBar QToolButton:pressed { background: #c0d8ff; }
QTabWidget::pane {
border: none;
background: transparent;
}
QTabBar::tab {
background: #e6edf6;
border: 1px solid #c0cbd6;
border-bottom: none;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
padding: 10px 16px;
margin-right: 4px;
font-weight: bold;
color: #2c3e50;
}
QTabBar::tab:selected {
background: #ffffff;
border-color: #a0b8d0;
border-bottom: 2px solid #4a8af4;
}
QTabBar::tab:hover:!selected { background: #d5e2f0; }
QDockWidget {
background: #f8faff;
border: 1px solid #c0cbd6;
border-radius: 6px;
}
QDockWidget::title {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 #e0edff, stop:1 #f0f8ff);
border-bottom: 1px solid #c0cbd6;
padding: 6px 12px;
font-weight: bold;
color: #1a3a5e;
}
QListWidget {
background: #f8faff;
border: none;
outline: none;
}
QListWidget::item {
border: 2px solid transparent;
border-radius: 6px;
padding: 4px;
margin: 2px;
}
QListWidget::item:selected {
background: #d0e4ff;
border: 2px solid #4a8af4;
}
QPushButton {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #f0f8ff, stop:1 #dce8f5);
border: 1px solid #a0b8d0;
border-radius: 8px;
padding: 8px 16px;
font-weight: bold;
color: #1a3a5e;
}
QPushButton:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #dce8f5, stop:1 #b0cce6);
border-color: #4a8af4;
}
QPushButton:pressed {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #b0cce6, stop:1 #90b0d0);
}
QLineEdit, QSpinBox, QComboBox, QTextEdit {
background: #ffffff;
border: 1px solid #c0cbd6;
border-radius: 6px;
padding: 6px 10px;
}
QLineEdit:focus, QSpinBox:focus, QComboBox:focus, QTextEdit:focus {
border: 2px solid #4a8af4;
}
QScrollBar:vertical {
background: #e6edf6;
width: 12px;
border-radius: 6px;
}
QScrollBar::handle:vertical {
background: #b0cce6;
border-radius: 6px;
min-height: 20px;
}
QScrollBar::handle:vertical:hover { background: #90b0d0; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
QScrollBar:horizontal {
background: #e6edf6;
height: 12px;
border-radius: 6px;
}
QScrollBar::handle:horizontal {
background: #b0cce6;
border-radius: 6px;
min-width: 20px;
}
QScrollBar::handle:horizontal:hover { background: #90b0d0; }
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }
QStatusBar {
background: #f0f8ff;
border-top: 1px solid #c0cbd6;
padding: 4px;
}
QToolTip {
background: #ffffff;
color: #1a3a5e;
border: 1px solid #a0b8d0;
border-radius: 4px;
padding: 4px 8px;
}
QPushButton#colorBtn {
min-height: 30px;
border: 2px solid #a0b8d0;
border-radius: 6px;
}
QPushButton#colorBtn:hover { border-color: #4a8af4; }
"""
def parse_page_range(text, max_page):
pages = set()
parts = re.split(r'[,;,;\s]+', text.strip())
for part in parts:
if '-' in part:
try:
a, b = part.split('-')
start = int(a) - 1
end = int(b) - 1
if start > end: start, end = end, start
pages.update(range(max(0, start), min(max_page, end + 1)))
except:
pass
else:
try:
p = int(part) - 1
if 0 <= p < max_page:
pages.add(p)
except:
pass
return sorted(pages)
def pil_to_pixmap(pil_img):
if pil_img.mode not in ("RGB", "RGBA"):
pil_img = pil_img.convert("RGBA")
qim = ImageQt.ImageQt(pil_img)
return QPixmap.fromImage(qim)
def render_latex_to_pixmap(latex_str, fontsize=12, dpi=100):
if not MATPLOTLIB_AVAILABLE:
return None
fig = Figure(figsize=(1, 1), dpi=dpi)
fig.text(0, 0.5, f"${latex_str}$", fontsize=fontsize, va='center')
fig.subplots_adjust(left=0.02, right=0.98, top=0.98, bottom=0.02)
fig.canvas.draw()
buf = io.BytesIO()
fig.savefig(buf, format='png', bbox_inches='tight', pad_inches=0.05)
buf.seek(0)
pil_img = Image.open(buf)
return pil_to_pixmap(pil_img)
class ImageEditorDialog(QDialog):
def __init__(self, pixmap, parent=None):
super().__init__(parent)
self.original_pixmap = pixmap
self.current_pixmap = pixmap
self.rotation = 0
self.scale_factor = 1.0
self.crop_rect = None
self.setWindowTitle("图片编辑")
self.setModal(True)
self.resize(600, 500)
layout = QVBoxLayout(self)
self.view = QGraphicsView()
self.scene = QGraphicsScene(self)
self.view.setScene(self.scene)
self.pixmap_item = QGraphicsPixmapItem(pixmap)
self.scene.addItem(self.pixmap_item)
self.view.setRenderHint(QPainter.Antialiasing)
self.view.setDragMode(QGraphicsView.ScrollHandDrag)
layout.addWidget(self.view)
ctrl_layout = QHBoxLayout()
rotate_btn = QPushButton("旋转90°")
rotate_btn.clicked.connect(lambda: self.rotate(90))
ctrl_layout.addWidget(rotate_btn)
zoom_in_btn = QPushButton("放大")
zoom_in_btn.clicked.connect(lambda: self.zoom(1.2))
ctrl_layout.addWidget(zoom_in_btn)
zoom_out_btn = QPushButton("缩小")
zoom_out_btn.clicked.connect(lambda: self.zoom(0.8))
ctrl_layout.addWidget(zoom_out_btn)
reset_btn = QPushButton("重置")
reset_btn.clicked.connect(self.reset)
ctrl_layout.addWidget(reset_btn)
ctrl_layout.addStretch()
ok_btn = QPushButton("确定")
ok_btn.clicked.connect(self.accept)
ctrl_layout.addWidget(ok_btn)
cancel_btn = QPushButton("取消")
cancel_btn.clicked.connect(self.reject)
ctrl_layout.addWidget(cancel_btn)
layout.addLayout(ctrl_layout)
def rotate(self, deg):
self.rotation = (self.rotation + deg) % 360
transform = QTransform().rotate(self.rotation)
self.pixmap_item.setTransform(transform)
def zoom(self, factor):
self.scale_factor *= factor
self.pixmap_item.setScale(self.scale_factor)
def reset(self):
self.rotation = 0
self.scale_factor = 1.0
self.pixmap_item.setTransform(QTransform())
self.pixmap_item.setScale(1.0)
def get_edited_pixmap(self):
img = self.original_pixmap.toImage()
if self.rotation != 0:
transform = QTransform().rotate(self.rotation)
img = img.transformed(transform, Qt.SmoothTransformation)
if self.scale_factor != 1.0:
new_size = img.size() * self.scale_factor
img = img.scaled(new_size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
return QPixmap.fromImage(img)
class PageContentEditor(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("页面内容编辑器")
self.setModal(True)
self.resize(800, 600)
self.layout = QVBoxLayout(self)
self.tab_widget = QTabWidget()
self.layout.addWidget(self.tab_widget)
self.text_edit = QTextEdit()
self.text_edit.setAcceptRichText(True)
self.text_edit.setFontPointSize(12)
self.tab_widget.addTab(self.text_edit, "📝 文本")
self.img_list = QListWidget()
self.img_list.setViewMode(QListWidget.IconMode)
self.img_list.setIconSize(QSize(120, 120))
self.img_list.setResizeMode(QListWidget.Adjust)
self.tab_widget.addTab(self.img_list, "🖼️ 图片")
self.formula_edit = QTextEdit()
self.formula_edit.setPlaceholderText("输入LaTeX公式,如: E = mc^2")
self.tab_widget.addTab(self.formula_edit, "📐 公式")
btn_layout = QHBoxLayout()
ok_btn = QPushButton("确定 (生成新页面)")
ok_btn.clicked.connect(self.accept)
cancel_btn = QPushButton("取消")
cancel_btn.clicked.connect(self.reject)
btn_layout.addStretch()
btn_layout.addWidget(ok_btn)
btn_layout.addWidget(cancel_btn)
self.layout.addLayout(btn_layout)
self.edited_images = []
def set_text(self, text):
self.text_edit.setPlainText(text)
def get_text(self):
return self.text_edit.toHtml()
def add_image_pixmap(self, pixmap, path):
item = QListWidgetItem(QIcon(pixmap), os.path.basename(path))
item.setData(Qt.UserRole, path)
self.img_list.addItem(item)
def get_edited_images(self):
paths = []
for i in range(self.img_list.count()):
paths.append(self.img_list.item(i).data(Qt.UserRole))
return paths
def get_formula_latex(self):
return self.formula_edit.toPlainText()
class PdfTab(QWidget):
def __init__(self, file_path, parent=None, blank=False):
super().__init__(parent)
self.file_path = file_path
self.blank = blank
if blank:
self.doc = fitz.open()
self.doc.new_page()
self.file_path = None
else:
self.doc = fitz.open(file_path)
self.current_page = 0
self.total_pages = len(self.doc)
self.unsaved = False
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(6, 6, 6, 6)
main_layout.setSpacing(8)
nav_toolbar = QToolBar()
nav_toolbar.setIconSize(QSize(28, 28))
nav_toolbar.setStyleSheet("QToolBar { background: transparent; }")
self.prev_act = QAction(self.style().standardIcon(QStyle.SP_ArrowBack), "上一页", self)
self.next_act = QAction(self.style().standardIcon(QStyle.SP_ArrowForward), "下一页", self)
self.prev_act.triggered.connect(self.prev_page)
self.next_act.triggered.connect(self.next_page)
nav_toolbar.addAction(self.prev_act)
nav_toolbar.addAction(self.next_act)
self.page_spin = QSpinBox()
self.page_spin.setRange(1, max(1, self.total_pages))
self.page_spin.setValue(1)
self.page_spin.setFixedWidth(60)
self.page_spin.valueChanged.connect(self.go_to_page)
nav_toolbar.addWidget(self.page_spin)
self.page_label = QLabel(f"/ {self.total_pages}")
self.page_label.setStyleSheet("font-weight: bold; color: #2c3e50;")
nav_toolbar.addWidget(self.page_label)
nav_toolbar.addSeparator()
zoom_in_act = QAction(self.style().standardIcon(QStyle.SP_ArrowUp), "放大", self)
zoom_out_act = QAction(self.style().standardIcon(QStyle.SP_ArrowDown), "缩小", self)
zoom_in_act.triggered.connect(lambda: self.zoom(1.25))
zoom_out_act.triggered.connect(lambda: self.zoom(0.8))
nav_toolbar.addAction(zoom_in_act)
nav_toolbar.addAction(zoom_out_act)
fit_act = QAction(self.style().standardIcon(QStyle.SP_FileDialogDetailedView), "适应页面", self)
fit_act.triggered.connect(self.fit_page)
nav_toolbar.addAction(fit_act)
main_layout.addWidget(nav_toolbar)
self.viewer = PdfViewer()
main_layout.addWidget(self.viewer)
self.status_label = QLabel("就绪")
self.status_label.setStyleSheet("color: #3a5a7a; padding: 4px;")
main_layout.addWidget(self.status_label)
self.update_page()
def update_page(self):
if self.total_pages == 0:
return
if self.current_page >= self.total_pages:
self.current_page = self.total_pages - 1
page = self.doc.load_page(self.current_page)
mat = fitz.Matrix(2.0, 2.0)
pix = page.get_pixmap(matrix=mat, alpha=False)
img = QImage(pix.samples, pix.width, pix.height, pix.stride, QImage.Format_RGB888)
self.viewer.display_page(QPixmap.fromImage(img))
self.page_spin.blockSignals(True)
self.page_spin.setRange(1, max(1, self.total_pages))
self.page_spin.setValue(self.current_page + 1)
self.page_spin.blockSignals(False)
self.page_label.setText(f"/ {self.total_pages}")
def fit_page(self):
self.viewer.fitInView(self.viewer.scene.itemsBoundingRect(), Qt.KeepAspectRatio)
def prev_page(self):
if self.current_page > 0:
self.current_page -= 1
self.update_page()
def next_page(self):
if self.current_page < self.total_pages - 1:
self.current_page += 1
self.update_page()
def go_to_page(self, num):
self.current_page = num - 1
self.update_page()
def zoom(self, factor):
self.viewer.scale(factor, factor)
def delete_pages(self, page_indices):
if not page_indices:
return
for idx in sorted(page_indices, reverse=True):
if 0 <= idx < self.total_pages:
self.doc.delete_page(idx)
self.total_pages = len(self.doc)
self.current_page = min(self.current_page, self.total_pages - 1)
if self.current_page < 0:
self.current_page = 0
self.unsaved = True
self.update_page()
def insert_blank_page(self, position=None):
if position is None or position > self.total_pages:
position = self.total_pages
self.doc.new_page(position)
self.total_pages += 1
self.current_page = position
self.unsaved = True
self.update_page()
def move_page(self, from_idx, to_idx):
if from_idx == to_idx or not (0 <= from_idx < self.total_pages and 0 <= to_idx < self.total_pages):
return
try:
self.doc.move_page(from_idx, to_idx)
except AttributeError:
page = self.doc.load_page(from_idx)
self.doc.insert_pdf(self.doc, from_page=from_idx, to_page=from_idx, start_at=to_idx)
if to_idx > from_idx:
self.doc.delete_page(from_idx)
else:
self.doc.delete_page(from_idx + 1)
self.unsaved = True
self.current_page = to_idx
self.update_page()
def move_pages_range(self, start, end, dest):
if start > end:
start, end = end, start
if dest < start:
count = end - start + 1
for i in range(count):
self.doc.move_page(start, dest + i)
self.current_page = dest
elif dest > end:
count = end - start + 1
for i in range(count):
self.doc.move_page(start, dest)
self.current_page = dest - count + 1
else:
return
self.unsaved = True
self.update_page()
def extract_text(self):
text = ""
for page in self.doc:
text += page.get_text()
return text
def extract_images(self, temp_dir):
img_list = []
for i, page in enumerate(self.doc):
image_list = page.get_images(full=True)
for img_index, img in enumerate(image_list):
xref = img[0]
try:
pix = fitz.Pixmap(self.doc, xref)
if pix.n - pix.alpha < 4:
path = os.path.join(temp_dir, f"page{i+1}_img{img_index+1}.png")
pix.save(path)
img_list.append(path)
except:
continue
return img_list
def replace_page_content(self, page_idx, html_text, image_paths, formula_latex_list):
page = self.doc.load_page(page_idx)
rect = page.rect
self.doc.delete_page(page_idx)
new_page = self.doc.new_page(page_idx, width=rect.width, height=rect.height)
new_page.insert_text(rect.tl, "", color=(1,1,1))
doc = QTextDocument()
doc.setHtml(html_text)
plain_text = doc.toPlainText()
new_page.insert_text(rect.tl + fitz.Point(36, 36), plain_text, fontsize=12, color=(0,0,0))
for i, img_path in enumerate(image_paths):
if os.path.exists(img_path):
x = 36 + (i % 2) * 300
y = 100 + (i // 2) * 200
r = fitz.Rect(x, y, x+200, y+150)
new_page.insert_image(r, filename=img_path)
for j, latex in enumerate(formula_latex_list):
if latex.strip():
pixmap = render_latex_to_pixmap(latex, fontsize=14)
if pixmap:
temp_path = os.path.join(os.path.dirname(self.file_path) if self.file_path else ".", "_temp_formula.png")
pixmap.save(temp_path)
x = 36 + (j % 2) * 300
y = 300 + (j // 2) * 150
r = fitz.Rect(x, y, x+200, y+100)
new_page.insert_image(r, filename=temp_path)
os.remove(temp_path)
self.unsaved = True
self.update_page()
def set_page_background(self, page_idx, color_rgb):
page = self.doc.load_page(page_idx)
rect = page.rect
annot = page.add_rect_annot(rect)
annot.set_colors(fill=color_rgb)
annot.update()
self.unsaved = True
self.update_page()
def highlight_text(self, page_idx, search_text, color=(1,1,0)):
page = self.doc.load_page(page_idx)
instances = page.search_for(search_text)
for inst in instances:
annot = page.add_highlight_annot(inst)
annot.set_colors(stroke=color)
annot.update()
if instances:
self.unsaved = True
self.update_page()
return len(instances)
def export_word(self, output_path):
cv = Converter(self.file_path)
cv.convert(output_path, start=0, end=None)
cv.close()
def save(self):
if self.file_path:
self.doc.save(self.file_path, garbage=4, deflate=True)
self.unsaved = False
def save_as(self, new_path):
self.doc.save(new_path, garbage=4, deflate=True)
self.file_path = new_path
self.unsaved = False
def close_doc(self):
self.doc.close()
class PdfViewer(QGraphicsView):
def __init__(self, parent=None):
super().__init__(parent)
self.scene = QGraphicsScene(self)
self.setScene(self.scene)
self.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform)
self.setDragMode(QGraphicsView.ScrollHandDrag)
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
self.setResizeAnchor(QGraphicsView.AnchorUnderMouse)
self.pixmap_item = None
def display_page(self, pixmap):
self.scene.clear()
self.pixmap_item = self.scene.addPixmap(pixmap)
self.setSceneRect(self.pixmap_item.boundingRect())
self.fitInView(self.pixmap_item, Qt.KeepAspectRatio)
def wheelEvent(self, event):
if event.modifiers() & Qt.ControlModifier:
factor = 1.25 if event.angleDelta().y() > 0 else 0.8
self.scale(factor, factor)
else:
super().wheelEvent(event)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle(f"{APP_NAME} v{VERSION}")
self.setGeometry(50, 50, 1400, 900)
self.setWindowIcon(self.style().standardIcon(QStyle.SP_FileIcon))
self.setStyleSheet(STYLE_SHEET)
self.tabs = QTabWidget()
self.tabs.setTabsClosable(True)
self.tabs.tabCloseRequested.connect(self.close_tab)
self.tabs.currentChanged.connect(self.on_tab_changed)
self.setCentralWidget(self.tabs)
self.create_dock_widgets()
self.create_menu()
self.create_toolbar()
self.temp_dir = os.path.join(os.path.dirname(__file__), "temp_extract")
os.makedirs(self.temp_dir, exist_ok=True)
self.translator = Translator()
self.settings = self.load_settings()
self.current_tool = "select"
self.statusBar().showMessage("欢迎使用 PDF 智编!")
def create_menu(self):
menubar = self.menuBar()
menubar.setStyleSheet("QMenuBar { background: #f0f8ff; }")
file_menu = menubar.addMenu("文件(&F)")
open_act = QAction(self.style().standardIcon(QStyle.SP_DialogOpenButton), "打开(&O)...", self)
open_act.triggered.connect(self.open_files)
file_menu.addAction(open_act)
new_act = QAction(self.style().standardIcon(QStyle.SP_FileIcon), "新建空白文档(&N)", self)
new_act.triggered.connect(self.new_blank_doc)
file_menu.addAction(new_act)
save_act = QAction(self.style().standardIcon(QStyle.SP_DialogSaveButton), "保存(&S)", self)
save_act.triggered.connect(self.save_current)
file_menu.addAction(save_act)
save_as_act = QAction("另存为(&A)...", self)
save_as_act.triggered.connect(self.save_as_current)
file_menu.addAction(save_as_act)
file_menu.addSeparator()
merge_act = QAction(self.style().standardIcon(QStyle.SP_FileDialogContentsView), "合并文件(&M)...", self)
merge_act.triggered.connect(self.merge_files)
file_menu.addAction(merge_act)
export_word_act = QAction(self.style().standardIcon(QStyle.SP_FileDialogInfoView), "导出为Word(&W)...", self)
export_word_act.triggered.connect(self.export_word)
file_menu.addAction(export_word_act)
file_menu.addSeparator()
exit_act = QAction(self.style().standardIcon(QStyle.SP_DialogCloseButton), "退出(&X)", self)
exit_act.triggered.connect(self.close)
file_menu.addAction(exit_act)
edit_menu = menubar.addMenu("编辑(&E)")
del_act = QAction(self.style().standardIcon(QStyle.SP_TrashIcon), "删除页面(&D)...", self)
del_act.triggered.connect(self.delete_pages_dialog)
edit_menu.addAction(del_act)
insert_act = QAction(self.style().standardIcon(QStyle.SP_FileIcon), "插入空白页(&I)...", self)
insert_act.triggered.connect(self.insert_blank_dialog)
edit_menu.addAction(insert_act)
move_act = QAction(self.style().standardIcon(QStyle.SP_ArrowForward), "移动页面(&M)...", self)
move_act.triggered.connect(self.move_pages_dialog)
edit_menu.addAction(move_act)
edit_menu.addSeparator()
edit_content_act = QAction(self.style().standardIcon(QStyle.SP_FileDialogDetailedView), "编辑页面内容(&C)...", self)
edit_content_act.triggered.connect(self.edit_page_content)
edit_menu.addAction(edit_content_act)
set_bg_act = QAction(self.style().standardIcon(QStyle.SP_FileDialogDetailedView), "设置背景色(&B)...", self)
set_bg_act.triggered.connect(self.set_bg_dialog)
edit_menu.addAction(set_bg_act)
tools_menu = menubar.addMenu("工具(&T)")
extract_act = QAction(self.style().standardIcon(QStyle.SP_FileDialogContentsView), "提取文字/图片/公式(&E)...", self)
extract_act.triggered.connect(self.extract_content_dialog)
tools_menu.addAction(extract_act)
translate_act = QAction(self.style().standardIcon(QStyle.SP_FileDialogInfoView), "翻译(&T)...", self)
translate_act.triggered.connect(self.translate_dialog)
tools_menu.addAction(translate_act)
highlight_act = QAction(self.style().standardIcon(QStyle.SP_FileDialogDetailedView), "高亮标注(&H)...", self)
highlight_act.triggered.connect(self.highlight_dialog)
tools_menu.addAction(highlight_act)
help_menu = menubar.addMenu("帮助(&H)")
about_act = QAction(self.style().standardIcon(QStyle.SP_MessageBoxInformation), "关于(&A)", self)
about_act.triggered.connect(self.about)
help_menu.addAction(about_act)
def create_toolbar(self):
toolbar = self.addToolBar("常用")
toolbar.setIconSize(QSize(32, 32))
toolbar.setStyleSheet("QToolBar { background: transparent; spacing: 8px; }")
open_act = QAction(self.style().standardIcon(QStyle.SP_DialogOpenButton), "打开", self)
open_act.triggered.connect(self.open_files)
toolbar.addAction(open_act)
new_act = QAction(self.style().standardIcon(QStyle.SP_FileIcon), "新建", self)
new_act.triggered.connect(self.new_blank_doc)
toolbar.addAction(new_act)
save_act = QAction(self.style().standardIcon(QStyle.SP_DialogSaveButton), "保存", self)
save_act.triggered.connect(self.save_current)
toolbar.addAction(save_act)
toolbar.addSeparator()
del_act = QAction(self.style().standardIcon(QStyle.SP_TrashIcon), "删除页", self)
del_act.triggered.connect(self.delete_pages_dialog)
toolbar.addAction(del_act)
insert_act = QAction(self.style().standardIcon(QStyle.SP_FileIcon), "插入空白", self)
insert_act.triggered.connect(self.insert_blank_dialog)
toolbar.addAction(insert_act)
toolbar.addSeparator()
zoom_in = QAction(self.style().standardIcon(QStyle.SP_ArrowUp), "放大", self)
zoom_in.triggered.connect(lambda: self.zoom_current(1.25))
toolbar.addAction(zoom_in)
zoom_out = QAction(self.style().standardIcon(QStyle.SP_ArrowDown), "缩小", self)
zoom_out.triggered.connect(lambda: self.zoom_current(0.8))
toolbar.addAction(zoom_out)
fit_act = QAction(self.style().standardIcon(QStyle.SP_FileDialogDetailedView), "适应", self)
fit_act.triggered.connect(self.fit_current_page)
toolbar.addAction(fit_act)
toolbar.addSeparator()
self.tool_combo = QComboBox()
self.tool_combo.addItems(["选择", "高亮", "下划线", "删除线", "添加文本"])
self.tool_combo.currentTextChanged.connect(self.set_tool)
self.tool_combo.setFixedWidth(100)
toolbar.addWidget(self.tool_combo)
def create_dock_widgets(self):
self.thumb_dock = QDockWidget("📄 页面缩略图", self)
self.thumb_list = QListWidget()
self.thumb_list.setViewMode(QListWidget.IconMode)
self.thumb_list.setIconSize(QSize(100, 140))
self.thumb_list.setResizeMode(QListWidget.Adjust)
self.thumb_list.setSpacing(8)
self.thumb_list.itemClicked.connect(self.goto_thumbnail)
self.thumb_dock.setWidget(self.thumb_list)
self.addDockWidget(Qt.LeftDockWidgetArea, self.thumb_dock)
self.prop_dock = QDockWidget("🎨 属性", self)
prop_widget = QWidget()
layout = QFormLayout(prop_widget)
layout.setSpacing(10)
self.font_combo = QFontComboBox()
self.font_size = QSpinBox()
self.font_size.setRange(8, 72)
self.font_size.setValue(12)
self.color_btn = QPushButton("选择颜色")
self.color_btn.setObjectName("colorBtn")
self.color_btn.clicked.connect(self.choose_color)
self.color_btn.setStyleSheet("background-color: #000000;")
layout.addRow("字体:", self.font_combo)
layout.addRow("字号:", self.font_size)
layout.addRow("颜色:", self.color_btn)
prop_widget.setLayout(layout)
self.prop_dock.setWidget(prop_widget)
self.addDockWidget(Qt.RightDockWidgetArea, self.prop_dock)
def open_files(self):
files, _ = QFileDialog.getOpenFileNames(self, "选择PDF文件", "", "PDF文件 (*.pdf)")
for f in files:
self.add_tab(f)
def new_blank_doc(self):
tab = PdfTab(None, blank=True)
index = self.tabs.addTab(tab, "未命名")
self.tabs.setCurrentIndex(index)
self.update_thumbnails(tab)
def add_tab(self, file_path):
if not os.path.exists(file_path):
return
tab = PdfTab(file_path)
index = self.tabs.addTab(tab, os.path.basename(file_path))
self.tabs.setCurrentIndex(index)
self.update_thumbnails(tab)
def close_tab(self, index):
tab = self.tabs.widget(index)
if tab.unsaved:
reply = QMessageBox.question(self, "未保存", f"文档 {tab.file_path or '未命名'} 已修改,是否保存?",
QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel)
if reply == QMessageBox.Save:
if tab.file_path is None:
path, _ = QFileDialog.getSaveFileName(self, "保存", "", "PDF文件 (*.pdf)")
if path:
tab.save_as(path)
else:
return
else:
tab.save()
elif reply == QMessageBox.Cancel:
return
tab.close_doc()
self.tabs.removeTab(index)
tab.deleteLater()
def get_current_tab(self):
return self.tabs.currentWidget()
def on_tab_changed(self, index):
if index >= 0:
tab = self.tabs.widget(index)
if tab:
self.update_thumbnails(tab)
def save_current(self):
tab = self.get_current_tab()
if tab:
if tab.file_path is None:
path, _ = QFileDialog.getSaveFileName(self, "保存", "", "PDF文件 (*.pdf)")
if path:
tab.save_as(path)
idx = self.tabs.currentIndex()
self.tabs.setTabText(idx, os.path.basename(path))
self.statusBar().showMessage(f"💾 已保存 {path}")
else:
tab.save()
self.statusBar().showMessage(f"✅ 已保存 {os.path.basename(tab.file_path)}")
def save_as_current(self):
tab = self.get_current_tab()
if not tab:
return
path, _ = QFileDialog.getSaveFileName(self, "另存为", "", "PDF文件 (*.pdf)")
if path:
tab.save_as(path)
idx = self.tabs.currentIndex()
self.tabs.setTabText(idx, os.path.basename(path))
self.statusBar().showMessage(f"💾 已另存为 {path}")
def delete_pages_dialog(self):
tab = self.get_current_tab()
if not tab:
return
text, ok = QInputDialog.getMultiLineText(self, "删除页面",
"输入要删除的页码:\n支持单页:5\n连续页:2-5\n混合:1,3-5,7")
if ok and text:
indices = parse_page_range(text, tab.total_pages)
if indices:
tab.delete_pages(indices)
self.update_thumbnails(tab)
self.statusBar().showMessage(f"🗑️ 已删除 {len(indices)} 页")
def insert_blank_dialog(self):
tab = self.get_current_tab()
if not tab:
return
pos, ok = QInputDialog.getInt(self, "插入空白页", "插入位置(页码):",
min=1, max=tab.total_pages+1, value=tab.total_pages+1)
if ok:
tab.insert_blank_page(pos-1)
self.update_thumbnails(tab)
self.statusBar().showMessage(f"📄 已在第{pos}页插入空白页")
def move_pages_dialog(self):
tab = self.get_current_tab()
if not tab:
return
start, ok = QInputDialog.getInt(self, "移动页面", "起始页码(从1开始):", min=1, max=tab.total_pages)
if not ok: return
end, ok = QInputDialog.getInt(self, "移动页面", "结束页码:", min=start, max=tab.total_pages)
if not ok: return
dest, ok = QInputDialog.getInt(self, "移动页面", "目标位置(页码):", min=1, max=tab.total_pages)
if not ok: return
tab.move_pages_range(start-1, end-1, dest-1)
self.update_thumbnails(tab)
self.statusBar().showMessage(f"↔️ 已将第{start}-{end}页移动到第{dest}页")
def edit_page_content(self):
tab = self.get_current_tab()
if not tab:
return
page_idx = tab.current_page
page = tab.doc.load_page(page_idx)
text = page.get_text()
images = tab.extract_images(self.temp_dir)
dlg = PageContentEditor(self)
dlg.set_text(text)
for img_path in images:
pix = QPixmap(img_path)
if not pix.isNull():
dlg.add_image_pixmap(pix, img_path)
if dlg.exec_() == QDialog.Accepted:
html = dlg.get_text()
img_paths = dlg.get_edited_images()
formula_latex = [dlg.get_formula_latex()]
tab.replace_page_content(page_idx, html, img_paths, formula_latex)
self.update_thumbnails(tab)
self.statusBar().showMessage("✏️ 页面内容已更新")
def set_bg_dialog(self):
tab = self.get_current_tab()
if not tab:
return
page, ok = QInputDialog.getInt(self, "背景色", "设置页码:", 1, 1, tab.total_pages)
if not ok: return
color = QColorDialog.getColor()
if not color.isValid(): return
rgb = (color.red()/255.0, color.green()/255.0, color.blue()/255.0)
tab.set_page_background(page-1, rgb)
self.statusBar().showMessage(f"🎨 已设置第{page}页背景色")
def merge_files(self):
files, _ = QFileDialog.getOpenFileNames(self, "选择要合并的PDF文件(按顺序)", "", "PDF文件 (*.pdf)")
if len(files) < 2:
QMessageBox.warning(self, "提示", "请至少选择两个文件")
return
save_path, _ = QFileDialog.getSaveFileName(self, "保存合并后的文件", "", "PDF文件 (*.pdf)")
if not save_path:
return
merged = fitz.open()
for f in files:
with fitz.open(f) as src:
merged.insert_pdf(src)
merged.save(save_path, garbage=4, deflate=True)
merged.close()
QMessageBox.information(self, "成功", f"合并完成,保存为 {save_path}")
self.add_tab(save_path)
def export_word(self):
tab = self.get_current_tab()
if not tab or tab.file_path is None:
QMessageBox.warning(self, "提示", "请先保存文档再导出")
return
path, _ = QFileDialog.getSaveFileName(self, "导出为Word", "", "Word文件 (*.docx)")
if path:
tab.export_word(path)
QMessageBox.information(self, "成功", "转换完成")
def extract_content_dialog(self):
tab = self.get_current_tab()
if not tab:
return
text = tab.extract_text()
dlg = QDialog(self)
dlg.setWindowTitle("提取内容")
dlg.setMinimumSize(600, 400)
layout = QVBoxLayout(dlg)
group = QTabWidget()
text_widget = QTextEdit()
text_widget.setPlainText(text)
group.addTab(text_widget, "📝 文字")
img_widget = QListWidget()
img_widget.setViewMode(QListWidget.IconMode)
img_widget.setIconSize(QSize(120, 120))
images = tab.extract_images(self.temp_dir)
for path in images:
pix = QPixmap(path)
if not pix.isNull():
item = QListWidgetItem(QIcon(pix), os.path.basename(path))
item.setData(Qt.UserRole, path)
img_widget.addItem(item)
group.addTab(img_widget, f"🖼️ 图片 ({len(images)})")
formula_widget = QLabel("公式提取需要OCR技术,目前暂不支持自动提取,您可以通过编辑器手动输入LaTeX。")
formula_widget.setWordWrap(True)
group.addTab(formula_widget, "📐 公式")
layout.addWidget(group)
btn_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Close)
btn_box.accepted.connect(dlg.accept)
btn_box.rejected.connect(dlg.reject)
layout.addWidget(btn_box)
dlg.exec_()
def translate_dialog(self):
text, ok = QInputDialog.getMultiLineText(self, "翻译", "输入要翻译的文本:")
if not ok or not text:
return
lang, ok = QInputDialog.getItem(self, "翻译目标", "选择目标语言:",
["中文", "英文", "日文", "韩文", "法文", "德文"], 0, False)
if not ok:
return
lang_map = {"中文": "zh-cn", "英文": "en", "日文": "ja", "韩文": "ko", "法文": "fr", "德文": "de"}
dest = lang_map.get(lang, "zh-cn")
try:
result = self.translator.translate(text, dest=dest).text
dlg = QMessageBox(self)
dlg.setWindowTitle("翻译结果")
dlg.setText(result)
dlg.setStandardButtons(QMessageBox.Ok | QMessageBox.Save)
if dlg.exec_() == QMessageBox.Save:
path, _ = QFileDialog.getSaveFileName(self, "保存翻译", "", "文本文件 (*.txt)")
if path:
with open(path, 'w', encoding='utf-8') as f:
f.write(result)
except Exception as e:
QMessageBox.critical(self, "错误", f"翻译失败: {e}")
def highlight_dialog(self):
tab = self.get_current_tab()
if not tab:
return
page, ok = QInputDialog.getInt(self, "高亮", "页码:", 1, 1, tab.total_pages)
if not ok: return
text, ok = QInputDialog.getText(self, "高亮", "要高亮的关键词:")
if not ok or not text: return
color = QColorDialog.getColor()
if not color.isValid():
color = QColor(255,255,0)
rgb = (color.red()/255.0, color.green()/255.0, color.blue()/255.0)
cnt = tab.highlight_text(page-1, text, color=rgb)
if cnt > 0:
QMessageBox.information(self, "成功", f"高亮了 {cnt} 处")
self.update_thumbnails(tab)
else:
QMessageBox.warning(self, "未找到", "未找到匹配文本")
def choose_color(self):
color = QColorDialog.getColor()
if color.isValid():
self.color_btn.setStyleSheet(f"background-color: {color.name()};")
def update_thumbnails(self, tab):
self.thumb_list.clear()
if not tab:
return
for i in range(tab.total_pages):
page = tab.doc.load_page(i)
pix = page.get_pixmap(dpi=72)
img = QImage(pix.samples, pix.width, pix.height, pix.stride, QImage.Format_RGB888)
qpix = QPixmap.fromImage(img)
icon = QIcon(qpix)
item = QListWidgetItem(icon, f"第{i+1}页")
item.setData(Qt.UserRole, i)
self.thumb_list.addItem(item)
def goto_thumbnail(self, item):
idx = item.data(Qt.UserRole)
tab = self.get_current_tab()
if tab:
tab.current_page = idx
tab.update_page()
def zoom_current(self, factor):
tab = self.get_current_tab()
if tab:
tab.zoom(factor)
def fit_current_page(self):
tab = self.get_current_tab()
if tab:
tab.fit_page()
def set_tool(self, tool):
self.current_tool = tool
self.statusBar().showMessage(f"🛠️ 当前工具: {tool}")
def load_settings(self):
if os.path.exists(SETTINGS_FILE):
try:
with open(SETTINGS_FILE, 'r') as f:
return json.load(f)
except:
return {}
return {}
def save_settings(self):
with open(SETTINGS_FILE, 'w') as f:
json.dump(self.settings, f)
def about(self):
QMessageBox.about(self, "关于",
f"{APP_NAME} v{VERSION}\n\n"
"📚 基于 PyQt5 + PyMuPDF + pdf2docx + googletrans\n"
"✨ 功能:多标签、页面管理、内容提取与编辑、文字/图片/公式插入、背景色、翻译、标注、PDF转Word等。\n"
"⚠️ 注意:对已有PDF内容的编辑采用"提取-修改-重绘"方式,会改变原排版。\n"
"❤️ 开源软件,仅供学习参考。")
def closeEvent(self, event):
self.save_settings()
for i in range(self.tabs.count()):
tab = self.tabs.widget(i)
if tab and tab.unsaved:
reply = QMessageBox.question(self, "未保存",
f"文档 {tab.file_path or '未命名'} 未保存,是否保存?",
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel)
if reply == QMessageBox.Cancel:
event.ignore()
return
elif reply == QMessageBox.Yes:
if tab.file_path is None:
path, _ = QFileDialog.getSaveFileName(self, "保存", "", "PDF文件 (*.pdf)")
if path:
tab.save_as(path)
else:
event.ignore()
return
else:
tab.save()
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
event.accept()
if __name__ == "__main__":
app = QApplication(sys.argv)
app.setStyle("Fusion")
font = QFont("Microsoft YaHei", 9)
app.setFont(font)
app.setWindowIcon(QIcon.fromTheme("pdf"))
window = MainWindow()
window.show()
sys.exit(app.exec_())
.spec文件
python
# main.spec
block_cipher = None
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=['fitz', 'pdf2docx', 'googletrans'],
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='PDFEditor',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
icon='app.ico', # 可选
)
.iss文件
python
[Setup]
AppName=精美PDF编辑器
AppVersion=1.0
DefaultDirName={pf}\PDFEditor
DefaultGroupName=PDFEditor
UninstallDisplayIcon={app}\PDFEditor.exe
Compression=lzma2
SolidCompression=yes
OutputDir=.
OutputBaseFilename=PDFEditor_Setup
[Files]
Source: "dist\PDFEditor.exe"; DestDir: "{app}"
Source: "dist\*"; DestDir: "{app}"; Flags: recursesubdirs
[Icons]
Name: "{group}\PDFEditor"; Filename: "{app}\PDFEditor.exe"
Name: "{userdesktop}\PDFEditor"; Filename: "{app}\PDFEditor.exe"
[Run]
Filename: "{app}\PDFEditor.exe"; Description: "启动 PDF编辑器"; Flags: postinstall nowait