#创作灵感#
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 环境创建与安装
# 创建一个名为PdfTool的环境,python版本号为3.10conda 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:图片编辑对话框,提供缩放、旋转、重置功能,并返回编辑后的图片。
其他辅助类。
数据流方向:用户在 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:完整代码
# -*- coding: utf-8 -*-import sysimport osimport reimport jsonimport shutilfrom PyQt5.QtWidgets import *from PyQt5.QtCore import *from PyQt5.QtGui import *import fitzfrom pdf2docx import Converterfrom googletrans import Translatorfrom PIL import Image, ImageQtimport ioimport numpy as nptry:import matplotlibmatplotlib.use('Qt5Agg')from matplotlib.figure import Figurefrom matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvasMATPLOTLIB_AVAILABLE = Trueexcept ImportError:MATPLOTLIB_AVAILABLE = FalseAPP_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) - 1end = int(b) - 1if start > end: start, end = end, startpages.update(range(max(0, start), min(max_page, end + 1)))except:passelse:try:p = int(part) - 1if 0 <= p < max_page:pages.add(p)except:passreturn 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 Nonefig = Figure(figsize=(1, 1), dpi=dpi)fig.text(0, 0.5, f"", 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 = pixmapself.current_pixmap = pixmapself.rotation = 0self.scale_factor = 1.0self.crop_rect = Noneself.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) % 360transform = QTransform().rotate(self.rotation)self.pixmap_item.setTransform(transform)def zoom(self, factor):self.scale_factor *= factorself.pixmap_item.setScale(self.scale_factor)def reset(self):self.rotation = 0self.scale_factor = 1.0self.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_factorimg = 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 pathsdef 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_pathself.blank = blankif blank:self.doc = fitz.open()self.doc.new_page()self.file_path = Noneelse:self.doc = fitz.open(file_path)self.current_page = 0self.total_pages = len(self.doc)self.unsaved = Falsemain_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:returnif self.current_page >= self.total_pages:self.current_page = self.total_pages - 1page = 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 -= 1self.update_page()def next_page(self):if self.current_page < self.total_pages - 1:self.current_page += 1self.update_page()def go_to_page(self, num):self.current_page = num - 1self.update_page()def zoom(self, factor):self.viewer.scale(factor, factor)def delete_pages(self, page_indices):if not page_indices:returnfor 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 = 0self.unsaved = Trueself.update_page()def insert_blank_page(self, position=None):if position is None or position > self.total_pages:position = self.total_pagesself.doc.new_page(position)self.total_pages += 1self.current_page = positionself.unsaved = Trueself.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):returntry: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 = Trueself.current_page = to_idxself.update_page()def move_pages_range(self, start, end, dest):if start > end:start, end = end, startif dest < start:count = end - start + 1for i in range(count):self.doc.move_page(start, dest + i)self.current_page = destelif dest > end:count = end - start + 1for i in range(count):self.doc.move_page(start, dest)self.current_page = dest - count + 1else:returnself.unsaved = Trueself.update_page()def extract_text(self):text = ""for page in self.doc:text += page.get_text()return textdef 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:continuereturn img_listdef replace_page_content(self, page_idx, html_text, image_paths, formula_latex_list):page = self.doc.load_page(page_idx)rect = page.rectself.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) * 300y = 100 + (i // 2) * 200r = 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) * 300y = 300 + (j // 2) * 150r = fitz.Rect(x, y, x+200, y+100)new_page.insert_image(r, filename=temp_path)os.remove(temp_path)self.unsaved = Trueself.update_page()def set_page_background(self, page_idx, color_rgb):page = self.doc.load_page(page_idx)rect = page.rectannot = page.add_rect_annot(rect)annot.set_colors(fill=color_rgb)annot.update()self.unsaved = Trueself.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 = Trueself.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 = Falsedef save_as(self, new_path):self.doc.save(new_path, garbage=4, deflate=True)self.file_path = new_pathself.unsaved = Falsedef 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 = Nonedef 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.8self.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):returntab = 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:returnelse:tab.save()elif reply == QMessageBox.Cancel:returntab.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:returnpath, _ = 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:returntext, 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:returnpos, 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:returnstart, ok = QInputDialog.getInt(self, "移动页面", "起始页码(从1开始):", min=1, max=tab.total_pages)if not ok: returnend, ok = QInputDialog.getInt(self, "移动页面", "结束页码:", min=start, max=tab.total_pages)if not ok: returndest, ok = QInputDialog.getInt(self, "移动页面", "目标位置(页码):", min=1, max=tab.total_pages)if not ok: returntab.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:returnpage_idx = tab.current_pagepage = 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:returnpage, ok = QInputDialog.getInt(self, "背景色", "设置页码:", 1, 1, tab.total_pages)if not ok: returncolor = QColorDialog.getColor()if not color.isValid(): returnrgb = (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, "提示", "请至少选择两个文件")returnsave_path, _ = QFileDialog.getSaveFileName(self, "保存合并后的文件", "", "PDF文件 (*.pdf)")if not save_path:returnmerged = 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, "提示", "请先保存文档再导出")returnpath, _ = 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:returntext = 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:returnlang, ok = QInputDialog.getItem(self, "翻译目标", "选择目标语言:",["中文", "英文", "日文", "韩文", "法文", "德文"], 0, False)if not ok:returnlang_map = {"中文": "zh-cn", "英文": "en", "日文": "ja", "韩文": "ko", "法文": "fr", "德文": "de"}dest = lang_map.get(lang, "zh-cn")try:result = self.translator.translate(text, dest=dest).textdlg = 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:returnpage, ok = QInputDialog.getInt(self, "高亮", "页码:", 1, 1, tab.total_pages)if not ok: returntext, ok = QInputDialog.getText(self, "高亮", "要高亮的关键词:")if not ok or not text: returncolor = 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:returnfor 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 = idxtab.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 = toolself.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()returnelif reply == QMessageBox.Yes:if tab.file_path is None:path, _ = QFileDialog.getSaveFileName(self, "保存", "", "PDF文件 (*.pdf)")if path:tab.save_as(path)else:event.ignore()returnelse: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文件
# main.specblock_cipher = Nonea = 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文件
[Setup]AppName=精美PDF编辑器AppVersion=1.0DefaultDirName={pf}\PDFEditorDefaultGroupName=PDFEditorUninstallDisplayIcon={app}\PDFEditor.exeCompression=lzma2SolidCompression=yesOutputDir=.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
夜雨聆风