当前位置: 代码网 > it编程>前端脚本>Python > Python使用PyQt5打造功能完备的PDF编辑器

Python使用PyQt5打造功能完备的PDF编辑器

2026年07月30日 Python 我要评论
前言pdf在日常办公、学术交流、电子出版等领域占据着举足轻重的地位。然而,与 word 等富文本编辑器不同,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 环境创建与安装

# 创建一个名为pdftool的环境,python版本号为3.10
conda create -n pdftool python=3.10

# pip普通安装,较慢
pip install pyqt5 pymupdf pdf2docx googletrans==4.0.0-rc1 pillow matplotlib
# pip清华源安装,较快
pip install pyqt5 pymupdf pdf2docx googletrans==4.0.0-rc1 pillow matplotlib -i https://pypi.tuna.tsinghua.edu.cn/simple
# pip读取文件安装
pip install -r requirement.txt

1.5 整体架构

整个编辑器采用 mvc(model-view-controller) 的变体,但由于 qt 本身提供了信号槽机制,我们不必严格遵循,而是将逻辑分离为以下几个部分:

  • mainwindow:主窗口,负责菜单、工具栏、停靠窗口、标签页容器(qtabwidget)的管理,以及全局操作(打开/保存/合并/导出等)的调度。
  • pdftab:每个标签页对应一个打开的 pdf 文档,包含文档对象、当前页码、导航控件、页面视图(pdfviewer)等,并实现页面级操作(删除、插入、移动、内容编辑)。
  • pdfviewer:自定义的 qgraphicsview,负责渲染和显示单页 pdf,支持缩放、拖拽、适应页面。
  • pagecontenteditor:一个对话框,用于编辑页面内容(文字、图片、公式),提供富文本编辑、图片列表、公式输入等功能,最终生成新的页面内容。
  • imageeditordialog:图片编辑对话框,提供缩放、旋转、重置功能,并返回编辑后的图片。
  • 其他辅助类(如 parse_page_range 工具函数,render_latex_to_pixmap 等)。

数据流方向:用户在 mainwindow 中触发操作(如删除页面),mainwindow 获取当前 pdftab 实例,调用其方法;pdftab 操作底层的 fitz.document 对象,更新内部状态,并刷新 pdfviewer 的显示。缩略图停靠窗口则监听标签切换事件,动态更新当前标签的缩略图列表。

第二章:界面设计与用户体验优化

2.1 布局规划

一个优秀的桌面应用应当布局合理、信息层级清晰。我们的编辑器遵循了经典 ide 风格:

  • 中央区域qtabwidget 承载多个文档标签,每个标签内包含导航工具栏和 pdf 视图。
  • 左侧停靠窗口:页面缩略图,方便用户快速跳转。
  • 右侧停靠窗口:属性面板,用于设置字体、字号、颜色等(目前主要用于演示,实际编辑时通过对话框完成)。
  • 顶部菜单栏:包含“文件”、“编辑”、“工具”、“帮助”四大菜单。
  • 顶部工具栏:将最常用的操作(打开、新建、保存、删除、插入、缩放、适应页面、工具选择)以图标按钮形式提供。
  • 底部状态栏:显示当前操作反馈。

整体的界面效果如下所示:

2.2 颜色与字体

为了营造生动活泼的视觉感受,我们使用了 蓝白渐变色 作为主色调,搭配深蓝文字和灰色边框,整体风格清新、现代。具体样式通过 qss(qt style sheets)实现,部分关键样式如下:

  • 主窗口背景:从上到下的浅蓝渐变(#e8f0fe → #d9e2f0)。

  • 菜单栏:白色背景,悬停时淡蓝高亮。

  • 标签页:未选中时为浅灰蓝,选中时白色并带蓝色下划线。

  • 按钮:从白到灰蓝渐变,悬停时加深边框和背景。

  • 输入框:白色背景,聚焦时出现蓝色外发光边框。

  • 滚动条:圆角设计,宽度适中。

此外,我们统一设置了全局字体为 “microsoft yahei”,字号 9,确保中英文混排美观。

2.3 图标与交互反馈

qt 内置了大量的标准图标(通过 qstyle.standardpixmap),我们尽可能使用这些图标,以减少外部资源依赖。对于菜单和工具栏中的动作,我们分别设置了图标,例如:

  • “打开”使用 sp_dialogopenbutton
  • “保存”使用 sp_dialogsavebutton
  • “删除”使用 sp_trashicon
  • “放大/缩小”使用 sp_arrowup/arrowdown
  • “适应页面”使用 sp_filedialogdetailedview

在交互反馈方面,我们充分利用了 qt 的悬浮(hover)和按下(pressed)状态样式,让用户清晰地感知当前可操作元素。同时,状态栏会显示每一步操作的文字提示(如“已删除 3 页”),并配以表情符号(如 🗑️、✏️)增强亲和力。

2.4 缩略图实现

缩略图停靠窗口中的每个条目实际上是一个 qlistwidgetitem,其图标为对应页面的缩略图(72 dpi 渲染),文字为页码。用户点击缩略图即可跳转到对应页面,实现快速导航。为了提升性能,缩略图仅在标签切换或页面变化时重新生成,不会频繁刷新。

2.5 响应式缩放与拖拽

pdfviewer 继承自 qgraphicsview,通过重载 wheelevent 支持按住 ctrl 滚动滚轮进行缩放(以鼠标位置为中心)。同时,setdragmode(qgraphicsview.scrollhanddrag) 允许用户按住鼠标拖拽平移视图,方便查看大图。此外,“适应页面”按钮会将视图重置为刚好显示完整页面。

第三章:多标签页管理

3.1 标签页容器

我们使用 qtabwidget 作为中央部件的容器,每个标签页是一个 pdftab 实例。pdftab 继承自 qwidget,内部包含一个垂直布局,布局中从上到下依次是导航工具栏、pdf 视图和状态标签。

当用户通过“打开”或“新建”创建文档时,mainwindow 会创建一个 pdftab 对象,并将它添加到 qtabwidget 中,同时设置标签标题为文件名或“未命名”。标签页右上角的关闭按钮(tabsclosable=true)允许用户关闭标签,关闭前会检查文档是否已保存。

3.2 文档对象的生命周期

每个 pdftab 持有一个 fitz.document 对象,在初始化时通过 fitz.open(file_path) 打开。如果用户新建空白文档,则调用 fitz.open() 创建一个空文档,并立即添加一个空白页(new_page)。文档的关闭在标签关闭时通过 close_doc() 方法完成,确保资源释放。

3.3 导航控件

导航工具栏包含“上一页”“下一页”按钮、页码输入框(qspinbox)和总页数标签。用户可以通过点击按钮或直接输入页码来切换页面。每次页面切换时,会调用 update_page() 方法重新渲染当前页的图片,并更新页码输入框的值和总页数标签。

3.4 标签切换与缩略图同步

当用户在不同标签间切换时,qtabwidget 的 currentchanged 信号会触发 on_tab_changed 槽函数,该函数获取当前标签并调用 update_thumbnails 更新左侧缩略图列表。这样,缩略图始终与当前激活的文档对应。

第四章:页面级操作实现

4.1 页面删除

页面删除是用户最常使用的操作之一。我们的“删除页面”功能通过一个对话框让用户输入页码或页码范围,支持以下格式:

  • 单页:5
  • 连续页:2-5
  • 混合:1,3-5,7

输入的字符串由 parse_page_range 函数解析,返回一个 0-based 的索引列表(已排序)。然后 pdftab.delete_pages 方法将这些索引从文档中删除(注意要从大到小删除,避免索引变化)。删除后,更新当前页码(若删除后无页则设为 0),并将文档标记为“未保存”。

4.2 插入空白页

用户可以在任意位置插入空白页。通过 qinputdialog.getint 获取插入位置(页码,1-based),然后将位置减 1 作为 fitz.document.new_page(position) 的参数。插入后,文档页数增加,当前页设置为插入页,并刷新显示。

4.3 页面移动

页面移动支持单页移动和多页连续移动。用户需要输入起始页码、结束页码和目标位置(均为 1-based),然后调用 move_pages_range 方法。该方法内部利用 fitz.document.move_page 方法(如果版本支持),否则采用“复制后删除”的兼容方式。移动后更新当前页为移动后的位置。

4.4 合并文件

合并文件功能通过 merge_files 实现。用户选择多个 pdf 文件(按顺序),然后指定保存路径。程序会创建一个新的 fitz.document,依次将每个源文件的所有页面插入(使用 insert_pdf 方法),最后保存并自动打开合并后的文件作为新标签。

第五章:内容提取与编辑——突破 pdf 的编辑限制

5.1 提取文字、图片、公式

在“编辑页面内容”对话框中,我们提供了三个标签页:

  • 文本:使用 qtextedit 显示当前页的纯文本(通过 page.get_text() 提取),用户可以在其中修改文字和格式(支持富文本)。
  • 图片:显示该页提取的所有图片(通过 page.get_images() 和 fitz.pixmap 保存为临时 png 文件),并以缩略图列表展示。用户虽然无法在当前版本中直接对图片进行位置调整或裁剪,但可以通过双击(待实现)打开 imageeditordialog 进行缩放和旋转(此功能已实现但尚未集成到主流程,可作为扩展点)。
  • 公式:由于 pdf 中的公式往往以图片或特殊字体呈现,自动识别为 latex 需要 ocr 技术,这超出了本文范围。因此我们仅提供一个文本输入框,让用户手动输入 latex 代码(如 e = mc^2),然后利用 matplotlib 渲染为图片。

编辑完成后,点击“确定”,程序会调用 replace_page_content 方法,该方法会:

  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=4 和 deflate=true 保存时压缩文件。

9.3 打包成可执行文件

使用 pyinstaller 将 python 脚本打包成单个 exe 文件(windows)。编写 .spec 文件指定隐藏导入(fitzpdf2docxgoogletranspil)和图标。由于依赖较多,打包后的 exe 约 80mb,但可独立运行,无需安装 python 环境。

9.4 制作安装程序

使用 inno setup 创建安装包,将 exe 文件和必要的资源放入程序目录,添加桌面快捷方式和开始菜单项,并提供卸载功能。

9.5 跨平台考虑

虽然我们的打包侧重 windows,但由于代码基于 python 和 qt,理论上可以在 linux 和 macos 上运行,只需相应调整打包方式(如使用 py2app 或 linuxdeploy)。

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

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:完整代码

# -*- 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文件

# 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文件

[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

以上就是python使用pyqt5打造功能完备的pdf编辑器的详细内容,更多关于pyqt5打造pdf编辑器的资料请关注代码网其它相关文章!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com