使用 PyQt5 打造功能完备的 PDF 编辑器:从设计到实现全解析

#创作灵感#

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 的原因主要有:

  1. Python 生态丰富 :Python 拥有海量的第三方库,如 PDF 处理库 PyMuPDFpdf2docx,图像处理库 Pillow,机器翻译库 googletrans,科学计算库 matplotlib 等,可以轻松集成到 PyQt 应用中。

  2. 开发效率高:Python 的简洁语法和 Qt 的 Designer 工具可以大幅缩短开发周期。

  3. 跨平台:一次编写,可在 Windows、macOS、Linux 上运行(通过 PyInstaller 打包)。

  4. 社区活跃:遇到问题容易找到解决方案。

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 标签切换与缩略图同步

当用户在不同标签间切换时,QTabWidgetcurrentChanged 信号会触发 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 方法,该方法会:

  1. 删除原页面。

  2. 在相同位置创建一个新页面(尺寸相同)。

  3. 将编辑后的文本(转成纯文本,因为插入文本时只支持纯文本,若需要富文本则需使用 insert_html 或逐字符设置格式,这里简化处理)插入到页面左上角(偏移 36 点)。

  4. 将编辑后的图片列表中的每张图片按固定布局(网格)插入到页面中(位置硬编码为两列,每行两图)。

  5. 将公式渲染为图片,并插入到页面下方。

这种"重绘"方式确实破坏了原有的排版,但它允许用户实质性地修改内容,对于某些场景(如填写表单、修改错误文本)是实用的。更高级的编辑(如保持原有布局)需要解析 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 类

每个标签页的核心逻辑。重要属性和方法:

  • docfitz.Document 对象。

  • current_page:当前显示页码(0-based)。

  • total_pages:总页数。

  • unsaved:修改标记。

  • update_page():渲染当前页并更新视图。

  • delete_pages(indices)insert_blank_page(position)move_pagemove_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_editQTextEdit 支持富文本编辑。

  • img_listQListWidget 显示图片缩略图。

  • formula_editQTextEdit 用于输入 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=4deflate=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 上运行,只需相应调整打包方式(如使用 py2applinuxdeploy)。


第十章:经验总结与未来展望

10.1 项目经验

  1. 合理选择第三方库:PyMuPDF 功能强大,但文档略显稀疏,需要大量查阅社区示例。pdf2docx 转换质量尚可,但对复杂文档支持有限。

  2. 界面与逻辑分离:通过将 UI 操作集中在 MainWindow,逻辑封装在 PdfTab,大大提高了代码的可维护性。

  3. 注重用户体验:及时的反馈、美观的界面、流畅的交互是吸引用户的关键。

  4. 处理异常情况:例如打开损坏的 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
相关推荐
Quz7 小时前
QML RangeSlider 组件:范围选择与双滑块样式
qt
luoyayun3617 小时前
【Qt for Harmony】 Qt 5.12.12 鸿蒙版 Windows 交叉编译
qt·harmonyos·qt for harmony
秋田君8 小时前
Qt_常用控件使用学习
数据库·qt·学习
wWYy.8 小时前
基于Raft的分布式Kv存储项目:raft.h
开发语言·分布式·qt
茯苓gao1 天前
嵌入式开发笔记:QSerialPort 完整使用指南——从基础 API 到工程实战
笔记·嵌入式硬件·qt·学习
丁劲犇1 天前
AI辅助快速实现动态多载波PSK随机信号源
人工智能·qt·ai·sdr·多相滤波·动态生成
Quz1 天前
QML Slider 组件:基础滑块、自定义手柄、刻度标记与自定义轨道
qt
dok121 天前
zynq qt 交叉编译
开发语言·qt