基于python代码实现的pdf文档阅读器。打开路径内pdf文件,涵盖了书签与目录功能。
从图片中看出,我们考虑了ui界面的极简性,以及上下翻页功能。
还有使用频率比较高的的pdf文件转换图片,将pdf文件中的每一页面分割为单独的图片,另外一个小细节,我们导出的图片同样是基于页面顺序。
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, simpledialog
import fitz # PyMuPDF
from PIL import Image, ImageTk
import json
import re
import os
import platform
from tkinter import font
import threading
from typing import Optional
import ttkbootstrap as tbclass MacPDFExpert:def __init__(self, root):self.root = rootself.root.title("PDF阅读器『六道』")self.root.geometry("1200x800+200+200")self.setup_system_style()# 文档状态self.current_page = 0self.pdf_document: Optional[fitz.Document] = Noneself.image_list = []self.zoom_level = 1.0self.dpi = 96# 用户数据self.bookmarks = {}self.annotations = {}self.search_results = []self.current_search_index = -1# 持久化配置self.settings_file = "pdf_expert_settings.json"self.annotations_file = "pdf_annotations.json"# UI组件self.create_widgets()self.setup_bindings()self.load_initial_data()def setup_system_style(self):"""根据操作系统应用相应视觉风格"""theme = "cosmo" if platform.system() == "Darwin" else "flatly"self.style = tb.Style(theme=theme)print(f"Available colors: {self.style.colors.__dict__}") # Debugging line to see available colorsself.root.configure(bg=self.style.colors.bg)# 字体配置system_font = "Helvetica" if platform.system() == "Darwin" else "Segoe UI"default_font = font.nametofont("TkDefaultFont")default_font.configure(family=system_font, size=12)# 控件样式self.style.configure("TButton",padding=(10, 5),relief="flat",font=(system_font, 12),anchor="center")# 通用样式self.style.configure("TProgressbar",thickness=3,troughcolor="#E5E5EA",background="#34C759")self.style.configure("Treeview",background="white",fieldbackground="white",bordercolor="#CECED2",font=(system_font, 11))self.style.map("Treeview",background=[("selected", "#007AFF")],foreground=[("selected", "white")])def create_widgets(self):"""创建所有界面组件"""# 主容器main_container = tb.Frame(self.root)main_container.pack(fill=tk.BOTH, expand=True)# 顶部工具栏toolbar = tb.Frame(main_container, padding=(10, 5))toolbar.pack(side=tk.TOP, fill=tk.X)# 操作按钮组btn_group = tb.Frame(toolbar)btn_group.pack(side=tk.LEFT)self.open_btn = tb.Button(btn_group, text="📂 打开", command=self.open_pdf, bootstyle="outline-primary")self.open_btn.pack(side=tk.LEFT, padx=2)self.export_menu = self.create_export_menu(btn_group)self.export_btn = tb.Button(btn_group, text="↩️ 导出", command=lambda:self.export_menu.post(self.export_btn.winfo_rootx(),self.export_btn.winfo_rooty() + 30), bootstyle="outline-secondary")self.export_btn.pack(side=tk.LEFT, padx=2)# 导航控件组nav_group = tb.Frame(toolbar)nav_group.pack(side=tk.LEFT, padx=20)self.prev_btn = tb.Button(nav_group, text="◀", width=3, command=self.prev_page, bootstyle="outline-info")self.prev_btn.pack(side=tk.LEFT)self.page_entry = tb.Entry(nav_group, width=5, font=("TkDefaultFont", 12))self.page_entry.pack(side=tk.LEFT, padx=5)self.page_entry.insert(0, "1")self.next_btn = tb.Button(nav_group, text="▶", width=3, command=self.next_page, bootstyle="outline-success")self.next_btn.pack(side=tk.LEFT)# 缩放控件组zoom_group = tb.Frame(toolbar)zoom_group.pack(side=tk.LEFT, padx=20)tb.Label(zoom_group, text="缩放:").pack(side=tk.LEFT)self.zoom_scale = tb.Scale(zoom_group, from_=25, to=400,command=lambda v: self.update_zoom(int(float(v))), orient="horizontal", length=150)self.zoom_scale.set(100)self.zoom_scale.pack(side=tk.LEFT, padx=5)# 搜索组search_group = tb.Frame(toolbar)search_group.pack(side=tk.RIGHT)self.search_entry = tb.Entry(search_group, width=25)self.search_entry.pack(side=tk.LEFT)self.search_btn = tb.Button(search_group, text="🔍 搜索", command=self.search_text, bootstyle="outline-warning")self.search_btn.pack(side=tk.LEFT, padx=5)# 主内容区content_paned = tb.Panedwindow(main_container, orient=tk.HORIZONTAL)content_paned.pack(fill=tk.BOTH, expand=True)# 左侧导航面板self.left_panel = tb.Frame(content_paned, width=220)content_paned.add(self.left_panel, weight=0)# 导航标签页self.nav_notebook = tb.Notebook(self.left_panel)self.nav_notebook.pack(fill=tk.BOTH, expand=True)# 目录标签toc_frame = tb.Frame(self.nav_notebook)self.toc_tree = tb.Treeview(toc_frame, show="tree", selectmode="browse")self.toc_tree_scroll = tb.Scrollbar(toc_frame, command=self.toc_tree.yview)self.toc_tree.configure(yscrollcommand=self.toc_tree_scroll.set)self.toc_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)self.toc_tree_scroll.pack(side=tk.RIGHT, fill=tk.Y)self.nav_notebook.add(toc_frame, text="目录")# 书签标签bookmark_frame = tb.Frame(self.nav_notebook)self.bookmark_list = tk.Listbox(bookmark_frame, bg="white", bd=0,font=("TkDefaultFont", 11), selectbackground="#007AFF")self.bookmark_scroll = tk.Scrollbar(bookmark_frame, command=self.bookmark_list.yview)self.bookmark_list.configure(yscrollcommand=self.bookmark_scroll.set)self.bookmark_list.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)self.bookmark_scroll.pack(side=tk.RIGHT, fill=tk.Y)self.nav_notebook.add(bookmark_frame, text="书签")# 主显示区self.right_panel = tb.Frame(content_paned)content_paned.add(self.right_panel, weight=1)# PDF显示画布self.canvas = tb.Canvas(self.right_panel, bg="white", highlightthickness=0)self.canvas.pack(fill=tk.BOTH, expand=True)# 状态栏self.status_bar = tb.Frame(self.root, height=24, style="StatusBar.TFrame")self.status_label = tb.Label(self.status_bar,text="就绪",anchor=tk.W,style="StatusBar.TLabel")self.status_label.pack(side=tk.LEFT, padx=10)self.status_bar.pack(side=tk.BOTTOM, fill=tk.X)# 进度条self.progress = tb.Progressbar(self.root, mode="determinate")# 上下文菜单self.context_menu = tb.Menu(self.root, tearoff=0)self.context_menu.add_command(label="添加注释", command=self.add_annotation)self.context_menu.add_command(label="删除书签", command=self.delete_bookmark)def create_export_menu(self, parent):"""创建导出功能的下拉菜单"""menu = tb.Menu(parent, tearoff=0)menu.add_command(label="导出当前页为图片...",command=self.export_current_page,accelerator="Cmd+S" if platform.system() == "Darwin" else "Ctrl+S")menu.add_command(label="导出全部页面为图片...",command=self.export_all_pages,accelerator="Cmd+Shift+E" if platform.system() == "Darwin" else "Ctrl+Shift+E")menu.add_separator()menu.add_command(label="导出选项...", command=self.show_export_settings)return menudef show_export_settings(self):"""显示导出设置对话框"""export_window = tb.Toplevel(self.root)export_window.title("导出设置")export_window.geometry("300x200+300+300")# DPI 设置dpi_frame = tb.Frame(export_window)dpi_frame.pack(pady=10)tb.Label(dpi_frame, text="DPI:").pack(side=tk.LEFT, padx=5)self.dpi_var = tk.StringVar(value=str(self.dpi))dpi_entry = tb.Entry(dpi_frame, textvariable=self.dpi_var, width=10)dpi_entry.pack(side=tk.LEFT, padx=5)# 应用按钮apply_btn = tb.Button(export_window, text="应用", command=self.apply_export_settings, bootstyle="primary-outline")apply_btn.pack(pady=10)def apply_export_settings(self):"""应用导出设置"""try:new_dpi = int(self.dpi_var.get())if new_dpi > 0:self.dpi = new_dpiself.save_settings()messagebox.showinfo("提示", "设置已保存")else:messagebox.showerror("错误", "请输入有效的DPI值")except ValueError:messagebox.showerror("错误", "请输入有效的数字")# 文件操作功能def open_pdf(self):"""打开PDF文件并初始化视图"""file_path = filedialog.askopenfilename(filetypes=[("PDF文件", "*.pdf"), ("所有文件", "*.*")])if file_path:try:if self.pdf_document:self.pdf_document.close()self.pdf_document = fitz.open(file_path)self.current_page = 0self.zoom_level = 1.0self.zoom_scale.set(100)self.update_ui_state()self.load_annotations()self.update_toc()self.show_page()except Exception as e:self.show_error_message(f"无法打开PDF文件: {str(e)}")def update_ui_state(self):"""更新界面状态"""has_doc = self.pdf_document is not Noneself.export_btn.state(["!disabled" if has_doc else "disabled"])self.prev_btn.state(["!disabled" if has_doc else "disabled"])self.next_btn.state(["!disabled" if has_doc else "disabled"])self.search_btn.state(["!disabled" if has_doc else "disabled"])self.zoom_scale.state(["!disabled" if has_doc else "disabled"])# 页面导航功能def jump_to_page(self):"""跳转到指定页码"""if not self.pdf_document:returntry:page_num = int(self.page_entry.get()) - 1if 0 <= page_num < len(self.pdf_document):self.current_page = page_numself.show_page()else:self.show_error_message("无效的页码")except ValueError:self.show_error_message("请输入有效的数字")def prev_page(self):"""跳转到上一页"""if self.current_page > 0:self.current_page -= 1self.show_page()def next_page(self):"""跳转到下一页"""if self.current_page < len(self.pdf_document) - 1:self.current_page += 1self.show_page()# 显示页面功能def show_page(self):"""显示当前页的内容"""if not self.pdf_document:returnpage = self.pdf_document[self.current_page]pix = page.get_pixmap(dpi=int(self.dpi * self.zoom_level)) # Ensure dpi is an integerimg = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)self.image_list.append(ImageTk.PhotoImage(img))self.canvas.delete("all")self.canvas.create_image(0, 0, image=self.image_list[-1], anchor="nw")self.page_entry.delete(0, tk.END)self.page_entry.insert(0, str(self.current_page + 1))# 缩放功能def update_zoom(self, value):"""更新缩放级别并重新显示页面"""self.zoom_level = float(value) / 100self.show_page()# 搜索功能def search_text(self):"""搜索文档中的文本"""query = self.search_entry.get().strip()if not query:self.show_error_message("请输入搜索内容")returnself.search_results.clear()for i in range(len(self.pdf_document)):text_instances = self.pdf_document[i].search_for(query)for inst in text_instances:self.search_results.append((i, inst))if self.search_results:self.current_search_index = 0self.highlight_search_result()else:self.show_info_message("未找到匹配项")def highlight_search_result(self):"""高亮显示搜索结果"""if not self.search_results or self.current_search_index == -1:returnpage_num, rect = self.search_results[self.current_search_index]self.jump_to_page(page_num)x0, y0, x1, y1 = rect.x0, rect.y0, rect.x1, rect.y1scale_factor = self.zoom_level * self.dpi / 72self.canvas.coords("highlight", x0 * scale_factor, y0 * scale_factor,x1 * scale_factor, y1 * scale_factor)self.canvas.itemconfig("highlight", outline="red", width=2)def next_search_result(self):"""跳转到下一个搜索结果"""if self.current_search_index >= len(self.search_results) - 1:self.current_search_index = 0else:self.current_search_index += 1self.highlight_search_result()def prev_search_result(self):"""跳转到上一个搜索结果"""if self.current_search_index <= 0:self.current_search_index = len(self.search_results) - 1else:self.current_search_index -= 1self.highlight_search_result()# 注释和书签功能def add_annotation(self):"""添加注释"""if not self.pdf_document:returncomment = simpledialog.askstring("添加注释", "输入您的注释:")if comment:page = self.pdf_document[self.current_page]annot = page.add_highlight_annot(page.rect)annot.update(contents=comment)self.save_annotations()def delete_bookmark(self):"""删除书签"""selected_item = self.bookmark_list.curselection()if selected_item:bookmark_name = self.bookmark_list.get(selected_item[0])del self.bookmarks[bookmark_name]self.bookmark_list.delete(selected_item)self.save_settings()# 目录功能def update_toc(self):"""更新目录树"""toc = self.pdf_document.get_toc(simple=False)self.populate_toc(toc)def populate_toc(self, toc):"""填充目录树"""for level, title, page_num, _rect in toc:parent_node = "" if level == 1 else self.toc_tree.get_children()[level - 2]node_id = self.toc_tree.insert(parent_node, "end", text=title, values=(level, title, page_num, _rect))self.toc_tree.bind("<Double-1>", lambda event: self.on_toc_double_click(event))def on_toc_double_click(self, event):"""双击目录项时跳转到对应页面"""item = self.toc_tree.selection()[0]_, _, page_num, _ = self.toc_tree.item(item, "values")self.current_page = page_num - 1self.show_page()# 导出功能def export_current_page(self):"""导出当前页为图片"""file_path = filedialog.asksaveasfilename(defaultextension=".png",filetypes=[("PNG文件", "*.png"), ("所有文件", "*.*")])if file_path:page = self.pdf_document[self.current_page]pix = page.get_pixmap(dpi=int(self.dpi * self.zoom_level)) # Ensure dpi is an integerpix.save(file_path)def export_all_pages(self):"""导出全部页面为图片"""folder_path = filedialog.askdirectory()if folder_path:for i in range(len(self.pdf_document)):page = self.pdf_document[i]pix = page.get_pixmap(dpi=int(self.dpi * self.zoom_level)) # Ensure dpi is an integeroutput_path = os.path.join(folder_path, f"page_{i + 1}.png")pix.save(output_path)# 设置和持久化功能def load_initial_data(self):"""加载初始设置和注释数据"""self.load_settings()self.load_annotations()def save_settings(self):"""保存设置数据"""settings = {"zoom_level": self.zoom_level,"dpi": self.dpi}with open(self.settings_file, "w") as f:json.dump(settings, f)def load_settings(self):"""加载设置数据"""try:with open(self.settings_file, "r") as f:settings = json.load(f)self.zoom_level = settings.get("zoom_level", 1.0)self.dpi = settings.get("dpi", 96)except FileNotFoundError:passdef save_annotations(self):"""保存注释数据"""annotations = {f"page_{i}": self.annotations.get(i, []) for i in range(len(self.pdf_document))}with open(self.annotations_file, "w") as f:json.dump(annotations, f)def load_annotations(self):"""加载注释数据"""try:with open(self.annotations_file, "r") as f:annotations = json.load(f)self.annotations = {int(k.replace("page_", "")): v for k, v in annotations.items()}except FileNotFoundError:pass# 绑定事件def setup_bindings(self):"""绑定各种事件"""self.root.bind("<Control-s>", lambda _: self.export_current_page())self.root.bind("<Control-Shift-E>", lambda _: self.export_all_pages())self.root.bind("<Command-s>", lambda _: self.export_current_page() if platform.system() == "Darwin" else "")self.root.bind("<Command-Shift-E>", lambda _: self.export_all_pages() if platform.system() == "Darwin" else "")self.root.bind("<Return>", lambda _: self.search_text())self.root.bind("<F3>", lambda _: self.next_search_result())self.root.bind("<Shift-F3>", lambda _: self.prev_search_result())# 辅助函数def show_error_message(self, message):"""显示错误消息框"""messagebox.showerror("错误", message)def show_info_message(self, message):"""显示信息消息框"""messagebox.showinfo("提示", message)if __name__ == "__main__":root = tb.Window(themename="cosmo" if platform.system() == "Darwin" else "flatly")app = MacPDFExpert(root)root.mainloop()