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

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

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

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

4.3 页面移动
页面移动支持单页移动和多页连续移动。用户需要输入起始页码、结束页码和目标位置(均为 1-based),然后调用 move_pages_range 方法。该方法内部利用 fitz.document.move_page 方法(如果版本支持),否则采用“复制后删除”的兼容方式。移动后更新当前页为移动后的位置。
4.4 合并文件
合并文件功能通过 merge_files 实现。用户选择多个 pdf 文件(按顺序),然后指定保存路径。程序会创建一个新的 fitz.document,依次将每个源文件的所有页面插入(使用 insert_pdf 方法),最后保存并自动打开合并后的文件作为新标签。
第五章:内容提取与编辑——突破 pdf 的编辑限制
5.1 提取文字、图片、公式
在“编辑页面内容”对话框中,我们提供了三个标签页:
- 文本:使用
qtextedit显示当前页的纯文本(通过page.get_text()提取),用户可以在其中修改文字和格式(支持富文本)。 - 图片:显示该页提取的所有图片(通过
page.get_images()和fitz.pixmap保存为临时 png 文件),并以缩略图列表展示。用户虽然无法在当前版本中直接对图片进行位置调整或裁剪,但可以通过双击(待实现)打开imageeditordialog进行缩放和旋转(此功能已实现但尚未集成到主流程,可作为扩展点)。 - 公式:由于 pdf 中的公式往往以图片或特殊字体呈现,自动识别为 latex 需要 ocr 技术,这超出了本文范围。因此我们仅提供一个文本输入框,让用户手动输入 latex 代码(如
e = mc^2),然后利用matplotlib渲染为图片。
编辑完成后,点击“确定”,程序会调用 replace_page_content 方法,该方法会:
- 删除原页面。
- 在相同位置创建一个新页面(尺寸相同)。
- 将编辑后的文本(转成纯文本,因为插入文本时只支持纯文本,若需要富文本则需使用
insert_html或逐字符设置格式,这里简化处理)插入到页面左上角(偏移 36 点)。 - 将编辑后的图片列表中的每张图片按固定布局(网格)插入到页面中(位置硬编码为两列,每行两图)。
- 将公式渲染为图片,并插入到页面下方。
这种“重绘”方式确实破坏了原有的排版,但它允许用户实质性地修改内容,对于某些场景(如填写表单、修改错误文本)是实用的。更高级的编辑(如保持原有布局)需要解析 pdf 的内容流并重写,复杂度极高,在开源方案中极少实现。
5.2 新建空白页并自由编辑
对于新建的空白文档或插入的空白页,用户同样可以使用“编辑页面内容”来添加文字、图片和公式。由于页面本身无内容,重绘后呈现的就是用户想要的内容,这实际上相当于一个简易的 pdf 制作工具。
5.3 图片编辑器的实现
我们实现了一个独立的 imageeditordialog,它包含一个 qgraphicsview 显示图片,并提供“旋转90°”“放大”“缩小”“重置”按钮。用户调整满意后点击“确定”,即可获得编辑后的 qpixmap。虽然该对话框尚未与主编辑流程完全集成(作为示例),但其代码完整,可以轻松嵌入。
5.4 背景色设置
用户可以为指定页面设置背景色。实现原理是在页面上添加一个覆盖整个页面的矩形注释,并设置填充颜色。这不会删除原有内容,而是作为背景层,效果类似批注。
第六章:辅助功能实现
6.1 pdf 转 word
导出 word 功能使用了 pdf2docx 库,只需传入源 pdf 路径和目标 docx 路径,调用 converter.convert() 即可。该库能够较好地保留文字和图片,但复杂排版(如表格、多列)可能丢失,这属于库本身的限制。
6.2 翻译功能
翻译功能基于 googletrans 库(实际上是谷歌翻译的非官方 api)。用户输入待翻译文本,选择目标语言(中、英、日、韩、法、德),程序调用 api 获取翻译结果,并弹出消息框显示。用户可以选择保存结果为文本文件。
6.3 标注高亮
高亮标注允许用户指定页码和关键词,程序使用 page.search_for() 查找所有匹配的文本区域,为每个区域添加高亮注释(add_highlight_annot),颜色可由用户通过颜色对话框选择。高亮后文档会标记为未保存。
第七章:界面美化与用户体验细节
7.1 qss 样式表详解
我们将所有样式集中定义在 style_sheet 字符串中,在 mainwindow 初始化时通过 self.setstylesheet(style_sheet) 应用。这样做的好处是样式统一,修改方便。以下是一些关键控件的样式设计思路:
- qmainwindow:使用渐变背景,避免单调。
- qtabwidget:标签页的圆角、边框、选中时的下划线,提升现代感。
- qdockwidget:标题栏渐变,使停靠窗口更具层次。
- qpushbutton:渐变背景、圆角、悬停和按下状态的变化,增强交互反馈。
- qlistwidget:缩略图条目在选中时具有蓝色边框,方便识别。
- qscrollbar:自定义滚动条宽度和颜色,使之与整体风格协调。
7.2 图标与表情符号
qt 的 qstyle.standardpixmap 提供了许多常用图标,我们尽量使用这些内置图标,避免额外图片资源。同时,在菜单项和状态栏消息中,我们适度加入表情符号(如 🗑️、✏️、📄、💾、🛠️),使界面更活泼,也直观地表达了操作意图。
7.3 用户反馈机制
状态栏是用户获得反馈的主要渠道。每次操作后,我们都会调用 self.statusbar().showmessage() 显示简短提示,如“✅ 已保存 xxx.pdf”。对于耗时操作(如 pdf 转 word),我们会在界面上弹出消息框提示完成。
7.4 未保存提醒
当用户尝试关闭含有未保存修改的标签或退出程序时,会弹出询问对话框,提醒用户保存、放弃或取消操作。这防止了数据丢失,是专业软件应有的行为。
第八章:代码结构深度剖析
8.1 主程序入口
if __name__ == "__main__" 块中创建 qapplication,设置全局字体和样式,然后实例化 mainwindow 并显示。这是标准的 pyqt 启动流程。
8.2 mainwindow 类
该类是控制中心,负责:
- 创建菜单栏、工具栏、停靠窗口。
- 管理标签页的打开、关闭、切换。
- 响应所有全局操作(如合并、导出、翻译等)。
- 维护设置文件的加载与保存(虽然本例未大量使用,但预留了接口)。
关键方法:
open_files():调用文件对话框,对每个选中的文件调用add_tab。new_blank_doc():创建空白标签。add_tab(file_path):新建pdftab并添加到qtabwidget。close_tab(index):检查未保存,关闭文档并移除标签。save_current()/save_as_current():保存或另存为。delete_pages_dialog()、insert_blank_dialog()、move_pages_dialog():弹出相应对话框,并调用当前标签的方法。edit_page_content():弹出页面内容编辑器,处理编辑后的结果。merge_files()、export_word()、translate_dialog()、highlight_dialog()等辅助功能。
8.3 pdftab 类
每个标签页的核心逻辑。重要属性和方法:
doc:fitz.document对象。current_page:当前显示页码(0-based)。total_pages:总页数。unsaved:修改标记。update_page():渲染当前页并更新视图。delete_pages(indices)、insert_blank_page(position)、move_page、move_pages_range:页面操作。replace_page_content(page_idx, html_text, image_paths, formula_latex_list):用新内容替换指定页。set_page_background(page_idx, color_rgb):设置背景色。highlight_text(page_idx, search_text, color):高亮文字。extract_text()、extract_images(temp_dir):内容提取。export_word(output_path):导出为 word。save()、save_as(new_path)、close_doc():保存和关闭。
8.4 pdfviewer 类
继承 qgraphicsview,负责显示 pdf 页面的图像。核心方法:
display_page(pixmap):清空场景并添加新的qgraphicspixmapitem,然后调用fitinview适应显示。wheelevent:按 ctrl 键时缩放。
8.5 pagecontenteditor 类
编辑页面内容的对话框,包含:
text_edit:qtextedit支持富文本编辑。img_list:qlistwidget显示图片缩略图。formula_edit:qtextedit用于输入 latex。set_text(text)、get_text()、add_image_pixmap()、get_edited_images()、get_formula_latex()等方法用于数据交换。
8.6 imageeditordialog 类
图片编辑对话框,实现旋转、缩放、重置,并返回编辑后的 qpixmap。
8.7 工具函数
parse_page_range(text, max_page):解析用户输入的页码字符串,返回索引列表。pil_to_pixmap(pil_img):pil image 转换为 qpixmap。render_latex_to_pixmap(latex_str, fontsize, dpi):使用 matplotlib 渲染 latex 为图片。
第九章:测试与打包发布
9.1 功能测试
我们对每个功能点进行了手工测试,涵盖正常流程和边界情况,例如:
- 打开多个文件,关闭其中某些,检查内存释放。
- 输入各种页码格式进行删除,确保索引正确。
- 插入空白页到开头、中间、末尾。
- 移动单页到不同位置。
- 编辑页面内容,插入文字、多张图片、多个公式,观察新页面的布局。
- 设置背景色,检查是否覆盖整页。
- 高亮文本,检查注释是否正确添加。
- 翻译功能在不同语言间切换。
- 导出 word,对比原 pdf。
- 保存/另存为,验证文件大小和内容。
9.2 性能优化
- 渲染页面时使用 2.0 倍放大矩阵(150 dpi),平衡清晰度和性能。
- 缩略图使用 72 dpi,减少内存占用。
- 图片提取时只提取像素数小于 4 的(非 cmyk 等特殊色彩空间),避免处理复杂图片。
- 使用
garbage=4和deflate=true保存时压缩文件。
9.3 打包成可执行文件
使用 pyinstaller 将 python 脚本打包成单个 exe 文件(windows)。编写 .spec 文件指定隐藏导入(fitz, pdf2docx, googletrans, pil)和图标。由于依赖较多,打包后的 exe 约 80mb,但可独立运行,无需安装 python 环境。
9.4 制作安装程序
使用 inno setup 创建安装包,将 exe 文件和必要的资源放入程序目录,添加桌面快捷方式和开始菜单项,并提供卸载功能。
9.5 跨平台考虑
虽然我们的打包侧重 windows,但由于代码基于 python 和 qt,理论上可以在 linux 和 macos 上运行,只需相应调整打包方式(如使用 py2app 或 linuxdeploy)。
第十章:经验总结与未来展望
10.1 项目经验
- 合理选择第三方库:pymupdf 功能强大,但文档略显稀疏,需要大量查阅社区示例。pdf2docx 转换质量尚可,但对复杂文档支持有限。
- 界面与逻辑分离:通过将 ui 操作集中在 mainwindow,逻辑封装在 pdftab,大大提高了代码的可维护性。
- 注重用户体验:及时的反馈、美观的界面、流畅的交互是吸引用户的关键。
- 处理异常情况:例如打开损坏的 pdf 文件、用户取消操作、网络超时等,都需要优雅地处理。
10.2 待改进之处
- 更智能的内容编辑:目前的重绘方式会丢失原排版。未来可尝试解析 pdf 的内容流,提取每个元素的坐标和属性,然后在编辑后按原坐标重排,但这需要深入 pdf 规范,工作量巨大。
- 公式自动识别:集成 ocr 引擎(如 tesseract + pix2tex)实现从图片到 latex 的自动转换。
- 批量处理:支持批量执行相同操作(如批量删除封面页、批量加水印)。
- 撤销/重做:实现命令历史,使用户敢于尝试。
- 更多导出格式:如 html、epub 等。
10.3 最后一点想说的
当前的实现仍有诸多限制,但它已经能够满足许多日常需求,并且提供了一个可扩展的平台。我希望这篇文章能够激发读者对桌面应用开发和 pdf 处理技术的兴趣,也欢迎各位技术同人提出宝贵意见,共同完善这个工具。
附录 a:完整代码
# -*- 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编辑器的资料请关注代码网其它相关文章!
发表评论