吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 4889|回复: 94
收起左侧

[Python 转载] pdf 加目录 - 工具

    [复制链接]
pyjiujiu 发表于 2026-6-14 11:40
本帖最后由 pyjiujiu 于 2026-6-22 12:33 编辑

一个小工具,用来给 pdf 加目录 (也叫outline),

更新:
- (26-6-22) v0.0.1 :
更新 导出/导入文本大纲(方便存档/备份); 空白非选中编辑窗 示例提示; 完善 两个格式粘贴检测;修复excel格式复制;增加对 不能同文件覆盖的回退机制(之前直接报错);
- (6-19) 提供 excel 复制/粘贴支持(制表符)

- 附带,deepseek 6-18开放的识图模式,用来做目录的例子 (笔者自己核对一遍,没有任何错误(很强))  chat.deepseek.com/share/td9tz310ismrubfoxv
具体使用方式,笔者一般是,自己开浏览器 快速复制 - 粘贴给 LLM 几张目录截图,附加prompt,llm出来结果,粘贴回软件,进行核对修改,页码偏移设置,预览,(大概5分钟以内能做完)


---分割线---

背景:
类似的软件难找 ,虽然有 但有两大痛点,1 不好用(也很复杂),2 都是面向 手动编辑(效率很低)

本工具,面向 LLM 大模型 OCR ,,而且经过 不知道多少次实战 ( qwen的小模型就行,比如Qwen3-Omni-Flash)
为了达到这个目标
- 自创一个 outline 的修改格式 (也叫 `Markdown 版`  因为和 md 格式原理差不多)
主要是方便 多模态的 LLM 进行 OCR识别,按格式输出,,写入本软件,自行内部转换
- 增加 ”页码偏移/补充“,考虑 ocr 的页码数字,是 目录体系,和 pdf文件 不一定对应,,需要两个系统进行换算,,本软件,变更每次计算,为无脑输入,软件自己负责计算(个别问题 个别修改可)
- 增加”预览功能“,根据 页码偏移,,可在保存pdf前,及时预览效果(主要保证 目录体系 和 pdf 文件,主体正确)


题外话:
本软件历时 接近一年半,刚开始还是 deepseek r1(25年2月份),大模型能力有限,迭代不了这个项目,加上pdf 要修改场景也不多,就搁浅了(不过 ocr 探索很多)
到现在 26年6月份, deepseek 的当前模型,已经随便迭代,怎么修改 怎么有,,加上最近 要读很多 扫描版书籍,就顺手完善下,
希望对有需求的人 提供点帮助。
---分割线---
markdown版 的修改格式
也很简单,就是 #开头 标识层级,后面”标题“  和 “页码” 用空格隔开
这样做的好处是,,LLM 偶然会多输出 空格,但不影响结果正确性(而 通行的 ”缩进版“ 麻烦就大了)


```

  # Level1 p1
  ## Level2 p2
  ### Level3 p3
```
---分割线---
技术细节补充


本项目在本人的代码仓维护,部分详细信息参考:github.com/fun-tailor/tool_pdf_outline_editor


- 主体来说,修改/编辑pdf目录,是两种格式,在本软件 右上角切换 (”缩进版“ 和 ”markdown版“)
- 此外,还支持解析 一个常见的 ”现成OCR格式“ (不支持编辑,需要转换为上两种格式)
支持解析形如 `页码 (p...): 标题` 的特殊目录格式(支持任意层级)。(#说明:这种往往随pdf文件提供,本软件提供解析支持,在`菜单栏` -> `导入` -> `导入 现成OCR版本`)



---分割线---
截图
操作界面.JPG

---分割线---
代码


[Python] 纯文本查看 复制代码
"""
PDF Outline Editor
新增功能:行加粗、状态栏、折叠额外条目、保存按钮、快捷键等。
"""

import tkinter as tk
from tkinter import filedialog, messagebox
from PIL import Image, ImageTk
import fitz
import re
import pathlib
import os

try:
    from tkinterdnd2 import DND_FILES, TkinterDnD
    DND_AVAILABLE = True
except ImportError:
    DND_AVAILABLE = False

try:
    from ctypes import windll
    windll.shcore.SetProcessDpiAwareness(2)
except Exception:
    pass


# ---------------- 设置 ----------------
DEFAULT_COLLAPSE = 2  # 默认折叠 的 beyond 层级 (2:第一层,第二层,默认展开),0 / None 为全展开
SET_PAEG_MODE = "UseOutlines" # 打开时显示大纲面板, 其他可选:UseNone, UseThumbs, FullScreen, UseAttachments, UseOC
PREVIEW_NUM = 3 # 预览前三条
TOOLTIP_SHOW_DELAY = 1200 #工具提示时延(ms)

# ---------------- 常量 ----------------
ICON_ADD = "img/add.png"
ICON_DEL = "img/delete.png"
ICON_INDENT = "img/indent.png"
ICON_UNINDENT = "img/unindent.png"

LLM_PROMPT = """帮我提取下目录信息,output in the following format

## The Format example 解析示例:

```
# Introduction 1
## Methods 10
### Methods_one 12
## Experiments 20
### Experiments_one 22
# End 30
```"""

TERMINOLOGY = """【术语解释】

目录页码:从 OCR 或原始目录中直接读取的页码,可能与 PDF文件 实际页数不符。
实际页码:PDF文件中真实的页面编号(从1开始),用于最终写入大纲。
偏移(Offset) = 实际页码 - 目录页码,通常为固定值。
额外条目:不在原目录体系中的自定义条目,可自由指定 实际页码。"""


class OutlineEntry:
    __slots__ = ['level','title','page','actual_page','is_extra','calc_offset']
    def __init__(self, level, title, page, is_extra=False):
        self.level = level
        self.title = title
        self.page = page
        self.actual_page = page
        self.is_extra = is_extra
        self.calc_offset = None

class Tooltip:
    def __init__(self, widget, text, show_delay=500, hide_delay=100):
        self.widget = widget
        self.text = text
        self.show_delay = max(TOOLTIP_SHOW_DELAY ,show_delay)      # 显示前的悬停时间(毫秒)
        self.hide_delay = hide_delay      # 隐藏前的离开时间(毫秒)
        self.tip_window = None
        self.show_id = None               # 显示定时器 ID
        self.hide_id = None               # 隐藏定时器 ID
        widget.bind('<Enter>', self.schedule_show)
        widget.bind('<Leave>', self.schedule_hide)

    def schedule_show(self, event=None):
        # 取消已有的隐藏定时器
        if self.hide_id:
            self.widget.after_cancel(self.hide_id)
            self.hide_id = None
        # 如果已经显示或没有文字,跳过
        if self.tip_window or not self.text:
            return
        # 安排显示定时器
        self.show_id = self.widget.after(self.show_delay, self.show)

    def show(self):
        # 实际显示提示窗口
        self.show_id = None
        if self.tip_window or not self.text:
            return
        try:
            x, y, _, _ = self.widget.bbox("insert")
        except Exception:
            x, y = 0, 0
        x += self.widget.winfo_rootx() + 25
        y += self.widget.winfo_rooty() + 25
        self.tip_window = tw = tk.Toplevel(self.widget)
        tw.wm_overrideredirect(True)
        tw.wm_geometry(f"+{x}+{y}")
        label = tk.Label(tw, text=self.text, justify=tk.LEFT,
                         background="#ffffe0", relief=tk.SOLID, borderwidth=1)
        label.pack()

    def schedule_hide(self, event=None):
        # 取消待定的显示定时器
        if self.show_id:
            self.widget.after_cancel(self.show_id)
            self.show_id = None
        # 安排隐藏定时器(短暂延迟,避免闪烁)
        if self.tip_window:
            self.hide_id = self.widget.after(self.hide_delay, self.hide)
        else:
            # 没有提示窗口,直接取消
            pass

    def hide(self):
        self.hide_id = None
        if self.tip_window:
            self.tip_window.destroy()
            self.tip_window = None

class OffsetManager:
    def __init__(self):
        self.mappings = []
        self.extra_entries = []   # 每项: (title, actual_page, position) position="head"或"tail"

    def add_mapping(self, cat_page, actual_page):
        self.mappings.append((cat_page, actual_page))

    def remove_mapping(self, index):
        if 0 <= index < len(self.mappings):
            del self.mappings[index]

    def add_extra(self, title, actual_page, position="head"):
        self.extra_entries.append((title, actual_page, position))

    def remove_extra(self, index):
        if 0 <= index < len(self.extra_entries):
            del self.extra_entries[index]

    def get_offset(self):
        if not self.mappings:
            return 0
        offsets = [actual - cat for cat, actual in self.mappings]
        if len(set(offsets)) == 1:
            return offsets[0]
        else:
            messagebox.showwarning("偏移不一致", "多个对照对的页码差值不一致,请检查后重新输入。")
            return None

    def apply_offset(self, entries):
        offset = self.get_offset()
        if offset is None:
            return False
        for entry in entries:
            if not entry.is_extra:
                entry.actual_page = entry.page + offset
        return True


class PDFOutlineEditor:
    def __init__(self, root):
        self.root = root
        self.current_file = None
        self.max_page_number = 0
        self.entries = []
        self.edit_mode = "indent"
        self.offset_mgr = OffsetManager()

        self.use_icons = all(os.path.exists(ic) for ic in [ICON_ADD, ICON_DEL, ICON_INDENT, ICON_UNINDENT])
        if self.use_icons:
            self.icons = {
                'add': ImageTk.PhotoImage(Image.open(ICON_ADD).resize((20,20))),
                'del': ImageTk.PhotoImage(Image.open(ICON_DEL).resize((20,20))),
                'indent': ImageTk.PhotoImage(Image.open(ICON_INDENT).resize((20,20))),
                'unindent': ImageTk.PhotoImage(Image.open(ICON_UNINDENT).resize((20,20)))
            }

        self.create_widgets()
        self.setup_dnd()
        self.bind_shortcuts()

        #占位符
        self.placeholder_text = self.get_placeholder_text()
        self.show_placeholder()   # 初始显示占位符
        self.text_editor.bind("<FocusIn>", self.on_focus_in)
        self.text_editor.bind("<FocusOut>", self.on_focus_out)
        self.is_placeholder = True

    def get_placeholder_text(self):
        if self.edit_mode == "indent":
            return "示例(缩进版):\n\n  一级标题 (1)\n    二级标题 (2)\n      三级标题 (3)"
        else:
            return "示例(Markdown版):\n\n# 一级标题 1\n## 二级标题 2\n### 三级标题 3"

    def show_placeholder(self, force=False):
        """当编辑器为空时显示占位符,不检查焦点状态"""
        content = self.text_editor.get("1.0", tk.END).rstrip('\n')
        if force or not content or content == self.placeholder_text:
            self.text_editor.delete("1.0", tk.END)
            self.placeholder_text = self.get_placeholder_text()  # 保证最新
            self.text_editor.insert("1.0", self.placeholder_text)
            self.text_editor.tag_configure("placeholder", foreground="gray")
            self.text_editor.tag_add("placeholder", "1.0", "end")
            self.text_editor.config(foreground="gray")
            self.is_placeholder = True
        # 若编辑器仍有焦点,立即移走
        if self.text_editor.focus_get() == self.text_editor:
            self.mode_btn.focus_set()   # 焦点移到切换按钮,光标消失

    def update_placeholder_state(self):
        content = self.text_editor.get("1.0", tk.END).rstrip('\n')
        if not content or content == self.placeholder_text:
            self.entries = [] 
            self.show_placeholder()
        else:
            self.is_placeholder = False
            self.text_editor.tag_remove("placeholder", "1.0", tk.END)
            self.text_editor.config(foreground="black")

    def on_focus_in(self, event):
        # 如果当前内容是占位符,则清空并恢复黑色
        if self.text_editor.get("1.0", tk.END).strip() == self.placeholder_text.strip():
            self.text_editor.delete("1.0", tk.END)
            self.text_editor.config(foreground="black")
            self.text_editor.tag_remove("placeholder", "1.0", tk.END)
            self.is_placeholder = False

    def on_focus_out(self, event):
        # 如果编辑器为空,重新显示占位符
        self.update_placeholder_state()

    def create_widgets(self):
        main_pane = tk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        main_pane.pack(fill=tk.BOTH, expand=1)

        left_frame = tk.Frame(main_pane)
        self.build_file_panel(left_frame)
        self.build_offset_panel(left_frame)
        
        main_pane.add(left_frame)

        right_frame = tk.Frame(main_pane)
        self.build_editor_panel(right_frame)
        main_pane.add(right_frame)

        # 底部状态栏
        self.status_bar = tk.Label(self.root, text="总页数:未加载", bd=1, relief=tk.SUNKEN, anchor=tk.W)
        self.status_bar.pack(side=tk.BOTTOM, fill=tk.X)

        self.build_menu()

    def build_menu(self):
        menubar = tk.Menu(self.root)
        self.root.config(menu=menubar)

        file_menu = tk.Menu(menubar, tearoff=0)
        file_menu.add_command(label="选择PDF", command=self.load_pdf)
        file_menu.add_command(label="保存到PDF", command=self.save_pdf, accelerator="Ctrl+S")
        file_menu.add_separator()
        file_menu.add_command(label="导入文本大纲...", command=self.import_txt)
        file_menu.add_command(label="导出文本大纲...", command=self.export_txt)
        file_menu.add_separator()
        file_menu.add_command(label="退出", command=self.root.quit)
        menubar.add_cascade(label="文件", menu=file_menu)

        import_menu = tk.Menu(menubar, tearoff=0)
        import_menu.add_command(label="导入现成OCR版本...", command=self.open_ocr_subwin)
        menubar.add_cascade(label="导入", menu=import_menu)

        help_menu = tk.Menu(menubar, tearoff=0)
        help_menu.add_command(label="显示LLM OCR提示词", command=self.show_prompt)
        help_menu.add_separator()
        help_menu.add_command(label="软件术语解释", command=self.show_terminology)
        menubar.add_cascade(label="帮助", menu=help_menu)

    def build_file_panel(self, parent):
        frame = tk.LabelFrame(parent, text="文件操作")
        tk.Button(frame, text="选择PDF", command=self.load_pdf).pack(pady=5, padx=5)
        self.file_label = tk.Label(frame, text="未选择文件", fg="gray")
        self.file_label.pack()
        if DND_AVAILABLE:
            self.drop_target = tk.Label(frame, text="拖放PDF文件至此", relief="sunken")
            self.drop_target.pack(pady=10, ipadx=20, ipady=10)
        frame.pack(pady=10, padx=5, fill=tk.X)

    def build_offset_panel(self, parent):
        frame = tk.LabelFrame(parent, text="页码补偿 & 预览", padx=12, pady=12)
        # 对照表区域
        map_frame = tk.Frame(frame)
        tk.Label(map_frame, text="目录页码 -> 实际页码").grid(row=0, column=0, columnspan=2)
        self.map_listbox = tk.Listbox(map_frame, height=4, width=30)
        self.map_listbox.grid(row=1, column=0, rowspan=3, sticky='nsew')
        scrollbar = tk.Scrollbar(map_frame, orient=tk.VERTICAL, command=self.map_listbox.yview)
        scrollbar.grid(row=1, column=1, rowspan=3, sticky='ns')
        self.map_listbox.config(yscrollcommand=scrollbar.set)
        tk.Label(map_frame, text="目录页码:").grid(row=1, column=2, sticky='w')
        self.cat_page_entry = tk.Entry(map_frame, width=10)
        self.cat_page_entry.grid(row=1, column=3)
        tk.Label(map_frame, text="实际页码:").grid(row=2, column=2, sticky='w')
        self.actual_page_entry = tk.Entry(map_frame, width=10)
        self.actual_page_entry.grid(row=2, column=3)
        tk.Button(map_frame, text="添加对照", command=self.add_mapping).grid(row=3, column=2, columnspan=2)
        tk.Button(map_frame, text="删除选中", command=self.del_mapping).grid(row=4, column=2, columnspan=2)
        map_frame.pack(pady=5)

        # 额外条目区域(可折叠)
        self.extra_visible = False
        self.extra_frame_container = tk.Frame(frame)
        self.toggle_btn = tk.Button(frame, text="展开额外条目 &#9656;", command=self.toggle_extra_panel)
        self.toggle_btn.pack(pady=5, anchor='w')

        extra_frame = tk.Frame(self.extra_frame_container)
        tk.Label(extra_frame, text="额外条目(不在目录体系中)").grid(row=0, column=0, columnspan=2)
        self.extra_listbox = tk.Listbox(extra_frame, height=3, width=30)
        self.extra_listbox.grid(row=1, column=0, rowspan=4, sticky='nsew')
        scrollbar2 = tk.Scrollbar(extra_frame, orient=tk.VERTICAL, command=self.extra_listbox.yview)
        scrollbar2.grid(row=1, column=1, rowspan=4, sticky='ns')
        self.extra_listbox.config(yscrollcommand=scrollbar2.set)
        tk.Label(extra_frame, text="标题:").grid(row=1, column=2, sticky='w')
        self.extra_title_entry = tk.Entry(extra_frame, width=10)
        self.extra_title_entry.grid(row=1, column=3)
        tk.Label(extra_frame, text="实际页码:").grid(row=2, column=2, sticky='w')
        self.extra_page_entry = tk.Entry(extra_frame, width=10)
        self.extra_page_entry.grid(row=2, column=3)
        tk.Label(extra_frame, text="位置:").grid(row=3, column=2, sticky='w')
        self.extra_pos_var = tk.StringVar(value="head")
        tk.OptionMenu(extra_frame, self.extra_pos_var, "head", "tail").grid(row=3, column=3)
        tk.Button(extra_frame, text="添加条目", command=self.add_extra_entry).grid(row=4, column=2, columnspan=2)
        tk.Button(extra_frame, text="删除选中", command=self.del_extra_entry).grid(row=5, column=2, columnspan=2)
        extra_frame.pack(pady=5)

        frame.pack(pady=10, padx=5, fill=tk.X)

        # 预览按钮
        preview_btn = tk.Button(parent, text="预览前三条目截图", command=self.preview_pages)
        preview_btn.pack(pady=5)
        Tooltip(preview_btn, "预览前三个条目的 PDF 页面截图")

    def toggle_extra_panel(self):
        if self.extra_visible:
            self.extra_frame_container.pack_forget()
            self.toggle_btn.config(text="展开额外条目 &#9656;")
        else:
            self.extra_frame_container.pack(pady=5)
            self.toggle_btn.config(text="折叠额外条目 &#9662;")
        self.extra_visible = not self.extra_visible

    def build_editor_panel(self, parent):
        frame = tk.Frame(parent)
        toolbar = tk.Frame(frame)
        self.create_tool_button(toolbar, 'add', "添加条目", self.add_entry, "添加新条目")
        self.create_tool_button(toolbar, 'del', "删除条目", self.delete_entry, "删除选中条目")
        self.create_tool_button(toolbar, 'indent', "增加缩进", self.indent_entry, "增加缩进层级")
        self.create_tool_button(toolbar, 'unindent', "减少缩进", self.unindent_entry, "减少缩进层级")
        self.mode_btn = tk.Button(toolbar, text="Markdown版", command=self.toggle_mode)
        self.mode_btn.pack(side=tk.RIGHT, padx=5)
        Tooltip(self.mode_btn, "切换缩进/Markdown编辑模式")
        toolbar.pack(fill=tk.X, pady=2)

        # 带滚动条的文本编辑器
        text_frame = tk.Frame(frame)
        text_frame.pack(fill=tk.BOTH, expand=1, padx=5, pady=5)
        self.text_editor = tk.Text(text_frame, wrap=tk.NONE, undo=True, width=50, height=35)
        scroll_y = tk.Scrollbar(text_frame, orient=tk.VERTICAL, command=self.text_editor.yview)
        self.text_editor.configure(yscrollcommand=scroll_y.set)
        self.text_editor.grid(row=0, column=0, sticky='nsew')
        scroll_y.grid(row=0, column=1, sticky='ns')
        text_frame.grid_rowconfigure(0, weight=1)
        text_frame.grid_columnconfigure(0, weight=1)

        # 加粗tag
        self.text_editor.tag_configure("bold", font=("TkDefaultFont", 10, "bold"))

        self.text_editor.bind("<Button-1>", self.on_click_select_line)
        self.text_editor.bind("<Double-1>", self.on_double_click_edit)

        # 增加 excel (标题\t页码)
        self.text_editor_menu = tk.Menu(self.text_editor, tearoff=0)
        self.text_editor_menu.add_command(label="复制为 Excel 格式", command=self.copy_as_excel)
        self.text_editor.bind("<Button-3>", self.show_text_menu)

        # 绑定快捷键和右键粘贴(覆盖默认)
        self.text_editor.bind('<Control-v>', self.smart_paste)
        self.text_editor.bind('<Control-V>', self.smart_paste)
        # 注意:也需要处理 <<Paste>> 虚拟事件,以兼容右键菜单或 Shift+Ins 粘贴
        self.text_editor.bind('<<Paste>>', self.smart_paste)

        # 保存
        tk.Button(frame, text="保存到PDF", command=self.save_pdf).pack(pady=5)
        frame.pack(fill=tk.BOTH, expand=1)

    def export_txt(self):
        """导出当前大纲为文本文件"""
        if not self.entries:
            messagebox.showwarning("提示", "大纲为空")
            return
        else:
            # 使用条目生成规范文本
            if self.edit_mode == "indent":
                text = self.entries_to_indent()
            else:
                text = self.entries_to_markdown()
        origin_file_path = pathlib.Path(self.current_file)
        file_path = filedialog.asksaveasfilename(
            defaultextension=".txt",
            initialdir=origin_file_path.parent,
            initialfile=f'大纲_{origin_file_path.stem}',
            filetypes=[("文本文件", "*.txt"), ("所有文件", "*.*")]
        )
        if not file_path:
            return
        try:
            with open(file_path, 'w', encoding='utf-8') as f:
                f.write(text)
            messagebox.showinfo("成功", f"大纲已导出到:{file_path}")
        except Exception as e:
            messagebox.showerror("错误", f"导出失败:{str(e)}")

    def import_txt(self):
        """从文本文件导入大纲,自动识别格式或按当前模式解析"""
        file_path = filedialog.askopenfilename(
            filetypes=[("文本文件", "*.txt"), ("所有文件", "*.*")]
        )
        if not file_path:
            return
        try:
            with open(file_path, 'r', encoding='utf-8') as f:
                raw = f.read()
        except Exception as e:
            messagebox.showerror("错误", f"读取文件失败:{str(e)}")
            return

        if not raw.strip():
            messagebox.showwarning("提示", "文件内容为空")
            return

        # 自动判断格式:第一非空行以 # 开头则按 Markdown,否则按缩进版尝试
        first_line = next((l for l in raw.splitlines() if l.strip()), "").strip()
        is_md = first_line.startswith("#")

        # 按检测到的模式解析
        if is_md:
            entries = self.parse_markdown_text(raw, suppress_warning=False)
            if not entries:
                # 如果 Markdown 解析完全失败,尝试缩进版
                entries = self.parse_indent_text(raw, suppress_warning=True)
                if entries:
                    if not messagebox.askyesno("格式不确定", "文件看起来不是标准 Markdown,但可解析为缩进版,是否导入?"):
                        return
                else:
                    messagebox.showerror("错误", "无法解析文件内容,请检查格式")
                    return
        else:
            entries = self.parse_indent_text(raw, suppress_warning=False)
            if not entries:
                entries = self.parse_markdown_text(raw, suppress_warning=True)
                if entries:
                    if not messagebox.askyesno("格式不确定", "文件看起来不是标准缩进版,但可解析为 Markdown,是否导入?"):
                        return
                else:
                    messagebox.showerror("错误", "无法解析文件内容,请检查格式")
                    return

        # 导入成功,替换当前条目并刷新显示
        self.entries = entries
        self.refresh_display()
        messagebox.showinfo("成功", f"已导入 {len(entries)} 条大纲条目")

    def is_indent_format(self, md_lines):
        """检查文本是否为缩进版格式(所有非空行都能解析为带括号页码的条目)"""
        if not md_lines:
            return None
        entries = self.parse_indent_text(raw_lines=md_lines)
        if len(entries) > 0:
            return entries
        return None

    def is_excel_format(self, text):
        """检查文本是否为 Excel 粘贴格式(每行包含一个制表符,右侧为纯数字或带括号的数字)"""
        lines = text.strip().splitlines()
        if not lines:
            return False
        for line in lines:
            if '\t' not in line:
                return False
            parts = line.split('\t')
            if len(parts) < 2:
                return False
            right = parts[-1].strip()
            # 允许页码带括号,如 "12" 或 "(12)"
            if right.startswith('(') and right.endswith(')'):
                right = right[1:-1].strip()
            if not right.isdigit():
                return False
        return True

    def convert_excel_to_editor_format(self, excel_text):
        """将 Excel 格式文本转换为当前编辑器模式的字符串,保留左侧空格作为层级"""
        lines = excel_text.strip().splitlines()
        result = []
        for line in lines:
            parts = line.split('\t')
            left = parts[0]
            right = parts[-1].strip()
            # 提取页码(去除括号)
            if right.startswith('(') and right.endswith(')'):
                page = right[1:-1].strip()
            else:
                page = right
            # 计算左侧缩进(前导空格)
            spaces = len(left) - len(left.lstrip(' '))
            title = left.lstrip(' ')
            if self.edit_mode == "indent":
                # 缩进版:空格保留 + 标题 (页码)
                result.append(f"{' ' * spaces}{title} ({page})")
            else:
                # Markdown版:根据空格数转换为 # 数量(每2空格一级)
                level = spaces // 2 + 1
                result.append(f"{'#' * level} {title} {page}")
        return '\n'.join(result)

    def smart_paste(self, event=None):
        """智能粘贴:若为 Excel 格式则自动转换并替换选区,否则默认粘贴"""
        try:
            clip_text = self.root.clipboard_get()
        except tk.TclError:
            clip_text = ""

        # Excel 格式
        if clip_text and self.is_excel_format(clip_text): # 整段是
            # 转换为当前编辑器格式
            converted = self.convert_excel_to_editor_format(clip_text)
            # 如果有选中内容,先删除
            if self.text_editor.tag_ranges("sel"):
                self.text_editor.delete(tk.SEL_FIRST, tk.SEL_LAST)
            self.text_editor.insert(tk.INSERT, converted)
            self.apply_bold_tags() #刷新bold
            self.parse_display()
            return "break"  # 阻止默认粘贴
        
        # markdown版 单独判断
        is_markdown = True
        markdown_lines = []
        raw_lines = [] #no stripe
        for l in clip_text.splitlines():
            l_s = l.strip()
            if l_s:
                markdown_lines.append(l_s)
                raw_lines.append(l)
                if is_markdown:
                    is_markdown = l_s.startswith('#')

        # 防错:缩进模式 → 检测 Markdown(非空行以 # 开头)
        if self.edit_mode == "indent":
            if markdown_lines and is_markdown:
                ask = messagebox.askokcancel("粘贴格式确认", 
                    "检测到粘贴内容为 Markdown 格式,是否转换为缩进版?")
                if ask:
                    entries = self.parse_markdown_text(md_lines=markdown_lines)
                    if entries:
                        # 转换为缩进版文本再插入
                        indent_text = self.entries_to_text(entries, mode="indent")
                        if self.text_editor.tag_ranges("sel"):
                            self.text_editor.delete(tk.SEL_FIRST, tk.SEL_LAST)
                        self.text_editor.insert(tk.INSERT, indent_text)
                        self.apply_bold_tags()   
                        return "break"
                    else:
                        messagebox.showwarning("转换失败", "无法解析 Markdown 内容")
                        return "break"    
                    
        # 防错:Markdown 模式 → 检测缩进 (原理同上)
        if self.edit_mode == "markdown" and (not is_markdown):
            if messagebox.askokcancel(
                "粘贴格式确认",
                "检测到粘贴内容可能为缩进版格式,是否转换为 Markdown 版?"
            ):
                indent_entries = self.is_indent_format(raw_lines)
                if not indent_entries:
                    return None
                markdown_text = self.entries_to_text(indent_entries, mode="markdown")
                if self.text_editor.tag_ranges("sel"):
                    self.text_editor.delete(tk.SEL_FIRST, tk.SEL_LAST)
                self.text_editor.insert(tk.INSERT, markdown_text)
                self.apply_bold_tags()   
                return "break"

        return None

    def copy_as_excel(self):
        """复制当前选中行(无选择则全部)为 Excel 格式(缩进空格 + 标题\t页码)"""
        try:
            sel_start = self.text_editor.index(tk.SEL_FIRST)
            sel_end = self.text_editor.index(tk.SEL_LAST)
            start_line = int(sel_start.split('.')[0])
            end_line = int(sel_end.split('.')[0])
            selected = True
        except tk.TclError:
            # 无选中:使用全部行(第1行到倒数第二行,因为最后一行是自动换行的空行)
            start_line = 1
            end_line = int(self.text_editor.index('end-1c').split('.')[0])
            selected = False

        lines = []
        for line_num in range(start_line, end_line + 1):
            line_text = self.text_editor.get(f"{line_num}.0", f"{line_num}.end")
            # 跳过空行
            if not line_text.strip():
                continue

            if self.edit_mode == "indent":
                title, page = self.parse_indent_line(line_text)
                # 如果解析失败则跳过该行
                if title is None:
                    continue
                level = (len(line_text) - len(line_text.lstrip(' '))) // 2 + 1
            else:  # markdown
                title, page, level = self.parse_markdown_line(line_text)
                if title is None:
                    continue

            indent = '  ' * (level - 1)
            lines.append(f"{indent}{title}\t{page}")

        if not lines:
            messagebox.showinfo("提示", "所选区域无有效条目")
            return

        excel_text = '\n'.join(lines)
        self.root.clipboard_clear()
        self.root.clipboard_append(excel_text)
        if not selected:
            messagebox.showinfo("已复制", "已全部复制为 Excel 格式")

    def show_text_menu(self, event):
        try:
            self.text_editor_menu.tk_popup(event.x_root, event.y_root)
        finally:
            self.text_editor_menu.grab_release()

    def create_tool_button(self, parent, icon_key, text, command, tooltip_text=None):
        if self.use_icons:
            btn = tk.Button(parent, image=self.icons[icon_key], command=command)
        else:
            btn = tk.Button(parent, text=text, command=command)
        btn.pack(side=tk.LEFT, padx=2, pady=2)
        if tooltip_text:
            Tooltip(btn, tooltip_text)
        return btn   # 返回以便后续使用

    def setup_dnd(self):
        if DND_AVAILABLE:
            try:
                self.root.drop_target_register(DND_FILES)
                self.root.dnd_bind('<<Drop>>', self.handle_drop)
            except Exception:
                pass

    def bind_shortcuts(self):
        self.root.bind('<Tab>', lambda e: self.indent_entry())
        self.root.bind('<Shift-Tab>', lambda e: self.unindent_entry())
        self.root.bind('<Control-s>', lambda e: self.save_pdf())
        self.text_editor.bind('<Tab>', self.focus_indent)
        self.text_editor.bind('<Shift-Tab>', self.focus_unindent)

    def focus_indent(self, event):
        self.indent_entry()
        return "break"

    def focus_unindent(self, event):
        self.unindent_entry()
        return "break"

    def handle_drop(self, event):
        filepath = event.data.strip('{}')
        if filepath.lower().endswith('.pdf'):
            self.load_pdf(filepath)

    def load_pdf(self, path=None):
        if not path:
            path = filedialog.askopenfilename(filetypes=[("PDF文件", "*.pdf")])
        if not path:
            return
        try:
            with fitz.open(path) as doc:
                self.max_page_number = doc.page_count
                toc = doc.get_toc()
                self.entries = []
                for level, title, page in toc:
                    self.entries.append(OutlineEntry(level, title, page))
                self.current_file = path
                self.file_label.config(text=os.path.basename(path), fg='blue')
                self.status_bar.config(text=f"总页数:{self.max_page_number}")

                # 清理 offset 管理器
                self.offset_mgr = OffsetManager()
                self.refresh_map_listbox()
                self.refresh_extra_listbox()
                self.cat_page_entry.delete(0, tk.END)
                self.actual_page_entry.delete(0, tk.END)
                self.extra_title_entry.delete(0, tk.END)
                self.extra_page_entry.delete(0, tk.END)

                self.refresh_display()
        except Exception as e:
            messagebox.showerror("错误", f"无法读取PDF:\n{str(e)}")

    def save_pdf(self):
        if not self.current_file:
            messagebox.showwarning("提示", "请先选择PDF文件")
            return
        if (not self.entries) or self.is_placeholder:
            messagebox.showwarning("提示", "大纲为空")
            return
        # 确保最新编辑内容
        self.parse_display()
        if not self.offset_mgr.apply_offset(self.entries):
            return
        if not self.validate_hierarchy():
            return

        toc = []
        # 先处理位置为"head"的额外条目
        head_extras = [e for e in self.offset_mgr.extra_entries if e[2] == "head"]
        tail_extras = [e for e in self.offset_mgr.extra_entries if e[2] == "tail"]
        for title, page, _ in head_extras:
            toc.append([1, title, page])
        for entry in self.entries:
            page = entry.actual_page
            if page > self.max_page_number:
                page = self.max_page_number
            elif page < 1:
                page = 1
            toc.append([entry.level, entry.title, page])
        for title, page, _ in tail_extras:
            toc.append([1, title, page]) 

        file_path = pathlib.Path(self.current_file)
        save_path = None
        while True:
            save_path = filedialog.asksaveasfilename(
                defaultextension=".pdf",
                initialdir=file_path.parent,
                initialfile=file_path.name,
                filetypes=[("PDF文件", "*.pdf")]
            )
            if not save_path:  # 用户取消
                return
            # 检查是否与原文件相同(比较绝对路径)
            if os.path.abspath(save_path) == os.path.abspath(self.current_file):
                messagebox.showwarning("禁止覆盖", "不能直接覆盖原文件,请另存为新文件。")
                continue  # 重新弹出保存对话框
            break  # 选择不同路径,继续保存

        try:
            with fitz.open(self.current_file) as doc:
                doc.set_toc(toc, collapse=DEFAULT_COLLAPSE)
                doc.set_pagemode(SET_PAEG_MODE)
                doc.save(save_path)
            messagebox.showinfo("成功", f"文件已保存至:{save_path}")
        except Exception as e:
            messagebox.showerror("错误", f"保存失败:\n{str(e)}")

    def validate_hierarchy(self):
        levels = [entry.level for entry in self.entries]
        problems = []
        prev = 0
        for i, lvl in enumerate(levels):
            if lvl - prev > 1:
                problems.append(f"第{i+1}行:从{prev}级跳至{lvl}级")
                h_prev,h_lvl = ("#" * prev),("#" * lvl)
                problems.append(f"\n{h_prev}{self.entries[i-1].title}\n{h_lvl}{self.entries[i].title}")
            prev = lvl
        if problems:
            # 使用 askokcancel 显示“确定”和“取消”按钮
            result = messagebox.askokcancel(
                "层级跳跃警告",
                "发现以下层级跳跃,\n" +
                "\n".join(problems) +
                "\n\n是否继续?"
            )
            return result  # 确定 or 取消
        return True

    def refresh_display(self):
        if not self.entries:
            self.placeholder_text = self.get_placeholder_text()
            self.show_placeholder(force=True) #空占位符
            return
        self.text_editor.delete(1.0, tk.END)
        if self.edit_mode == "indent":
            text = self.entries_to_indent()
        else:
            text = self.entries_to_markdown()
        self.text_editor.insert(1.0, text)
        self.apply_bold_tags()
        self.is_placeholder = False
        self.text_editor.config(foreground="black")
        self.text_editor.tag_remove("placeholder", "1.0", tk.END)

    def parse_display(self):
        if self.is_placeholder:
            return
        raw = self.text_editor.get(1.0, tk.END)
        if self.edit_mode == "indent":
            self.entries = self.parse_indent_text(raw)
        else:
            self.entries = self.parse_markdown_text(raw)

    def entries_to_indent(self):
        lines = []
        for e in self.entries:
            indent = "  " * (e.level - 1)
            lines.append(f"{indent}{e.title} ({e.page})")
        return "\n".join(lines)

    def entries_to_markdown(self):
        lines = []
        for e in self.entries:
            prefix = "#" * e.level
            lines.append(f"{prefix} {e.title} {e.page}")
        return "\n".join(lines)
    
    def entries_to_text(self, entries, mode):
        """将条目列表转换为指定模式的文本"""
        lines = []
        for e in entries:
            if mode == "indent":
                indent = "  " * (e.level - 1)
                lines.append(f"{indent}{e.title} ({e.page})")
            else:
                prefix = "#" * e.level
                lines.append(f"{prefix} {e.title} {e.page}")
        return "\n".join(lines)

    def parse_indent_text(self,raw_text=None,*, raw_lines=None, suppress_warning=False):
        entries = []
        potential_lines = []       # (行号, 原始行)
        matched_indices = set()    # 成功解析的行号
        lines = raw_lines or raw_text.splitlines()

        for idx, line in enumerate(lines, start=1):
            if not line.strip():
                continue
            potential_lines.append((idx, line))
            spaces = len(line) - len(line.lstrip(' '))
            level = spaces // 2 + 1
            content = line.lstrip()
            m = re.match(r'^(.+)\s+\((\d+)\)$', content)
            if not m:
                m = re.match(r'^(.+)\s+(\d+)$', content)
            if m:
                title, page = m.group(1).strip(), int(m.group(2))
                entries.append(OutlineEntry(level, title, page))
                matched_indices.add(idx)

        if not suppress_warning and len(potential_lines) != len(entries):
            missing = [idx for idx, _ in potential_lines if idx not in matched_indices]
            msg = f"警告:漏解析了 {len(missing)} 行(共 {len(potential_lines)} 个非空行)\n\n"
            for idx in missing[:3]:
                line_content = next(line for i, line in potential_lines if i == idx)
                msg += f"行 {idx}: {line_content}\n"
            if len(missing) > 3:
                msg += f"... 等共 {len(missing)} 行"
            messagebox.showwarning("解析警告", msg)
        return entries

    def parse_markdown_text(self, text=None, /,md_lines=None,suppress_warning=False):
        entries = []
        lines = md_lines or text.splitlines()

        # 记录所有潜在标题行(以 # 开头)及其行号,同时记录匹配成功的行号
        potential_lines = []      # (行号, 去除首尾空格的原始行)
        matched_indices = set()   # 成功匹配的行号

        for idx, raw_line in enumerate(lines, start=1):
            line = raw_line.strip()
            if not line or not line.startswith('#'):
                continue

            potential_lines.append((idx, line))
            m = re.match(r'^(#+)\s+(.+)\s+(\d+)$', line)
            if m:
                level = len(m.group(1))
                title = m.group(2).strip()
                page = int(m.group(3))
                entries.append(OutlineEntry(level, title, page))
                matched_indices.add(idx)

        # 核对漏解析
        if not suppress_warning and len(potential_lines) != len(entries):
            missing = [idx for idx, _ in potential_lines if idx not in matched_indices]
            msg = f"警告:漏解析了 {len(missing)} 行(共 {len(potential_lines)} 个 # 标题行)\n\n"
            for idx in missing:
                line_content = next(line for i, line in potential_lines if i == idx)
                msg += f"行 {idx}: {line_content}\n"
            messagebox.showwarning("解析警告", msg)

        return entries

    def apply_bold_tags(self):
        self.text_editor.tag_remove("bold", "1.0", tk.END)
        for line_num in range(1, int(self.text_editor.index('end').split('.')[0])):
            line_text = self.text_editor.get(f"{line_num}.0", f"{line_num}.end")
            if self.edit_mode == "indent":
                # 无缩进或以0个空格开头的(一级)
                if not line_text.startswith(" "):
                    self.text_editor.tag_add("bold", f"{line_num}.0", f"{line_num}.end")
            else:
                if line_text.startswith("# ") and not line_text.startswith("##"):
                    self.text_editor.tag_add("bold", f"{line_num}.0", f"{line_num}.end")

    def get_nonempty_lines(self, text):
        """返回所有非空行的 (行号, 行内容) 列表,行号从1开始"""
        lines = []
        for idx, line in enumerate(text.splitlines(), start=1):
            if line.strip():
                lines.append((idx, line))
        return lines

    def find_unparsed_lines(self, text, mode):
        """检查文本中哪些行在指定模式下无法解析,返回 (行号, 内容) 列表"""
        missing = []
        for line_num, line in self.get_nonempty_lines(text):
            if mode == "indent":
                # 检查是否匹配缩进格式(允许带括号或不带括号的页码)
                match_indent = re.match(r'^(\s*)\S', line)
                if not match_indent:
                    missing.append((line_num, line))
                    continue
                content = line.lstrip()
                m = re.match(r'^(.+)\s+\((\d+)\)$', content)
                if not m:
                    m = re.match(r'^(.+)\s+(\d+)$', content)
                if not m:
                    missing.append((line_num, line))
            else:  # markdown
                line_stripped = line.strip()
                if not line_stripped.startswith('#'):
                    missing.append((line_num, line))
                    continue
                m = re.match(r'^(#+)\s+(.+)\s+(\d+)$', line_stripped)
                if not m:
                    missing.append((line_num, line))
        return missing
    
    def toggle_mode(self):

        if self.is_placeholder:
            # 仅更新占位符文本,不改变编辑状态
            self.edit_mode = "markdown" if self.edit_mode == "indent" else "indent"
            self.mode_btn.config(text="缩进版" if self.edit_mode == "markdown" else "Markdown版")
            old_placeholder = self.placeholder_text
            self.placeholder_text = self.get_placeholder_text()
            # 如果当前内容等于旧占位符,直接替换为新占位符
            current = self.text_editor.get("1.0", "end-1c")
            if current.strip() == old_placeholder.strip():
                self.text_editor.delete("1.0", "end")
                self.text_editor.insert("1.0", self.placeholder_text)
                self.text_editor.tag_add("placeholder", "1.0", "end")
                # 焦点不变,若原来有光标则留在新文本末尾,用户点击即可清除
            else:
                # 内容为空时也显示新占位符
                if not current.strip():
                    self.show_placeholder()
            # 若编辑器仍有焦点,立即移走
            if self.text_editor.focus_get() == self.text_editor:
                self.mode_btn.focus_set()   # 焦点移到切换按钮,光标消失
            return
        
        raw = self.text_editor.get(1.0, tk.END)
        if self.edit_mode == "indent":
            target_mode,target_text = "markdown","Markdown版"
        else:
            target_mode,target_text = "indent","缩进版"

        # 用目标模式解析,但不弹出内部警告(我们自行处理)
        if self.edit_mode == "markdown":
            new_entries = self.parse_markdown_text(raw, suppress_warning=True)
        else:
            new_entries = self.parse_indent_text(raw, suppress_warning=True)

        # 检查未解析行
        missing = self.find_unparsed_lines(raw, self.edit_mode)
        if missing:
            msg = f"切换到「{target_text}」模式时,有 {len(missing)} 行无法解析,将丢失这些行。\n\n"
            for line_num, line_content in missing[:3]:
                msg += f"行 {line_num}: {line_content}\n"
            if len(missing) > 3:
                msg += f"... 等共 {len(missing)} 行"
            msg += "\n\n是否继续?"
            if not messagebox.askokcancel("解析警告", msg):
                return  # 取消切换

        # 执行切换
        self.edit_mode = target_mode
        self.mode_btn.config(text="缩进版" if target_mode == "markdown" else "Markdown版")
        self.entries = new_entries
        self.refresh_display()

    def add_entry(self):
        # 获取当前光标所在行
        cursor_idx = self.text_editor.index(tk.INSERT)
        line_num = int(cursor_idx.split('.')[0])
        line_text = self.text_editor.get(f"{line_num}.0", f"{line_num}.end")
        
        # 解析该行,获取层级和页码
        if self.edit_mode == "indent":
            title, page = self.parse_indent_line(line_text)
            level = (len(line_text) - len(line_text.lstrip(' '))) // 2 + 1
        else:
            title, page, level = self.parse_markdown_line(line_text)
        
        if page is None:
            # 解析失败,使用默认值
            page = 1
            level = 1 if self.edit_mode == "indent" else 1
        
        # 构造新条目文本
        if self.edit_mode == "indent":
            indent = "  " * (level - 1)
            new_line = f"{indent}新条目 ({page})"
        else:
            prefix = "#" * level
            new_line = f"{prefix} 新条目 {page}"
        
        # 在当前行下方插入
        self.text_editor.insert(f"{line_num}.end", f"\n{new_line}")
        self.parse_display()
        self.update_placeholder_state()

    def delete_entry(self):
        # 删除须全选,避免误删
        try:
            sel = self.text_editor.tag_ranges("sel")
            if sel:
                self.text_editor.delete(sel[0], sel[1])
                self.parse_display()
                self.update_placeholder_state()
            else:
                messagebox.showinfo("提示", "请先点击行选择")
        except Exception:
            pass

    def indent_entry(self):
        self.adjust_indent(2)

    def unindent_entry(self):
        self.adjust_indent(-2)

    def adjust_indent(self, delta):
        try:
            start_line = int(self.text_editor.index(tk.SEL_FIRST).split('.')[0])
            end_line = int(self.text_editor.index(tk.SEL_LAST).split('.')[0])
        except tk.TclError:
            cursor_line = int(self.text_editor.index(tk.INSERT).split('.')[0])
            start_line = end_line = cursor_line

        for line_num in range(start_line, end_line + 1):
            line_text = self.text_editor.get(f"{line_num}.0", f"{line_num}.end")
            if self.edit_mode == "indent":
                current_indent = len(line_text) - len(line_text.lstrip(' '))
                new_indent = max(0, current_indent + delta)
                new_text = ' ' * new_indent + line_text.lstrip(' ')
            else:  # markdown 模式
                # 行首 # 个数
                m = re.match(r'^(#+)', line_text.lstrip())
                current_level = len(m.group(1)) if m else 1
                new_level = max(1, current_level + delta // 2)  # delta 仍为 ±2,每2空格对应1级
                # 去除原开头空格和 # 
                content = line_text.lstrip()
                if content.startswith('#'):
                    content = re.sub(r'^#+\s?', '', content)  # 去掉原有 # 和可能的空格
                new_prefix = '#' * new_level + ' '
                new_text = new_prefix + content
            self.text_editor.replace(f"{line_num}.0", f"{line_num}.end", new_text)
        
        self.text_editor.tag_remove("sel", "1.0", tk.END)
        self.text_editor.tag_add("sel", f"{start_line}.0", f"{end_line}.end")
        self.parse_display()
        self.apply_bold_tags()
        self.update_placeholder_state()

    def on_click_select_line(self, event):
        index = self.text_editor.index(f"@{event.x},{event.y}")
        line_start = index.split('.')[0] + ".0"
        line_end = index.split('.')[0] + ".end"
        self.text_editor.tag_remove("sel", "1.0", tk.END)
        self.text_editor.tag_add("sel", line_start, line_end)

    def on_double_click_edit(self, event):
        index = self.text_editor.index(f"@{event.x},{event.y}")
        line_num = int(index.split('.')[0])
        line_text = self.text_editor.get(f"{line_num}.0", f"{line_num}.end")
        if self.edit_mode == "indent":
            title, page = self.parse_indent_line(line_text)
            level = (len(line_text) - len(line_text.lstrip(' '))) // 2 + 1
        else:
            title, page, level = self.parse_markdown_line(line_text)
        if title is None:
            return
        dialog = EditEntryDialog(self.root, title, page, level, self.max_page_number)
        self.root.wait_window(dialog.top)
        if dialog.result:
            new_title, new_page, new_level = dialog.result
            if self.edit_mode == "indent":
                new_line = "  " * (new_level - 1) + f"{new_title} ({new_page})"
            else:
                new_line = "#" * new_level + f" {new_title} {new_page}"
            self.text_editor.replace(f"{line_num}.0", f"{line_num}.end", new_line)
            self.parse_display()
            self.update_placeholder_state()

    def parse_indent_line(self, text):
        content = text.lstrip()
        m = re.match(r'^(.+)\s+\((\d+)\)$', content)
        if m:
            return m.group(1).strip(), int(m.group(2))
        return None, None

    def parse_markdown_line(self, text):
        m = re.match(r'^(#+)\s+(.+)\s+(\d+)$', text.strip())
        if m:
            level = len(m.group(1))
            return m.group(2).strip(), int(m.group(3)), level
        return None, None, None

    def open_ocr_subwin(self):
        sub = tk.Toplevel(self.root)
        sub.title("导入现成OCR版本")
        sub.geometry("500x400")
        tk.Label(sub, text="粘贴OCR内容(格式:页码 (p...): 标题)").pack(pady=5)
        text = tk.Text(sub, wrap=tk.WORD, width=60, height=15)
        text.pack(padx=10, pady=5, fill=tk.BOTH, expand=True)

        # 示例文本
        EXAMPLE = (
            "1 (p1): 示例\n"
            "3 (p2): 第1章 标题\n"
            "3 (p2-1): 第一节 标题\n"
            "5 (p2-2-1): 第二节 第一子节 标题\n"
        )
        text.insert("1.0", EXAMPLE)
        text.tag_configure("placeholder", foreground="grey")
        text.tag_add("placeholder", "1.0", "end")

        def on_focus_in(event):
            if text.get("1.0", "end-1c").strip() == EXAMPLE.strip():
                text.delete("1.0", "end")
                text.config(foreground="black")

        def on_focus_out(event):
            if not text.get("1.0", "end-1c").strip():
                text.insert("1.0", EXAMPLE)
                text.tag_add("placeholder", "1.0", "end")

        text.bind("<FocusIn>", on_focus_in)
        text.bind("<FocusOut>", on_focus_out)

        def do_import():
            raw = text.get(1.0, tk.END)
            # 如果内容仍是示例,视为空
            if raw.strip() == EXAMPLE.strip():
                raw = ""
            new_entries = self.parse_ocr_text(raw)
            if not new_entries:
                messagebox.showwarning("错误", "未能解析出有效条目")
                return
            self.entries = new_entries
            self.refresh_display()
            sub.destroy()

        tk.Button(sub, text="确定导入", command=do_import).pack(pady=10)

    def parse_ocr_text(self, raw):
        entries = []
        lines = raw.splitlines()
        non_empty = 0
        valid = 0
        for line in lines:
            line = line.strip()
            if not line:
                continue
            non_empty += 1
            # 匹配: 页码 (p层级链): 标题
            m = re.match(r'(\d+)\s+\(p([\d\-]+)\):\s*(.*)', line)
            if not m:
                continue
            valid += 1
            cat_page = int(m.group(1))
            p_chain = m.group(2)          # 如 "2-1-1"
            title = m.group(3).strip()
            level = len(p_chain.split('-'))   # 段数即层级
            entries.append(OutlineEntry(level, title, cat_page))

        if valid < non_empty:
            messagebox.showwarning("解析警告",
                f"输入了 {non_empty} 个非空行,仅解析出 {valid} 个条目,请检查格式。")
        return entries

    def add_mapping(self):
        try:
            cat = int(self.cat_page_entry.get())
            actual = int(self.actual_page_entry.get())
        except ValueError:
            messagebox.showwarning("错误", "请输入有效数字")
            return
        self.offset_mgr.add_mapping(cat, actual)
        self.refresh_map_listbox()
        self.cat_page_entry.delete(0, tk.END)
        self.actual_page_entry.delete(0, tk.END)

    def del_mapping(self):
        sel = self.map_listbox.curselection()
        if sel:
            self.offset_mgr.remove_mapping(sel[0])
            self.refresh_map_listbox()

    def refresh_map_listbox(self):
        self.map_listbox.delete(0, tk.END)
        for cat, actual in self.offset_mgr.mappings:
            self.map_listbox.insert(tk.END, f"{cat} -> {actual}")

    def add_extra_entry(self):
        title = self.extra_title_entry.get().strip()
        if not title:
            messagebox.showwarning("错误", "请输入标题")
            return
        try:
            page = int(self.extra_page_entry.get())
        except ValueError:
            messagebox.showwarning("错误", "请输入有效页码")
            return
        position = self.extra_pos_var.get()  # "head" or "tail"
        self.offset_mgr.add_extra(title, page, position)
        self.refresh_extra_listbox()
        self.extra_title_entry.delete(0, tk.END)
        self.extra_page_entry.delete(0, tk.END)

    def del_extra_entry(self):
        sel = self.extra_listbox.curselection()
        if sel:
            self.offset_mgr.remove_extra(sel[0])
            self.refresh_extra_listbox()

    def refresh_extra_listbox(self):
        self.extra_listbox.delete(0, tk.END)
        for title, page, pos in self.offset_mgr.extra_entries:
            self.extra_listbox.insert(tk.END, f"[{'从头' if pos=='head' else '从尾'}] {title} : {page}")

    def preview_pages(self):
        # 同步最新的编辑器内容
        self.parse_display()

        if not self.current_file or not self.entries:
            messagebox.showwarning("提示", "请先加载PDF并确保大纲不为空")
            return
        target_entries = []
        for level in range(1, 5):
            for e in self.entries:
                if e.level == level and len(target_entries) < 3:
                    target_entries.append(e)
            if len(target_entries) >= PREVIEW_NUM:
                break
        if not target_entries:
            messagebox.showwarning("提示", "没有有效条目")
            return
        preview = tk.Toplevel(self.root)
        preview.title("预览前三条目对应页面")
        canvas = tk.Canvas(preview, width=600, height=800)
        canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        scrollbar = tk.Scrollbar(preview, orient=tk.VERTICAL, command=canvas.yview)
        scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
        canvas.configure(yscrollcommand=scrollbar.set)
        inner = tk.Frame(canvas)
        canvas.create_window((0,0), window=inner, anchor='nw')
        try:
            doc = fitz.open(self.current_file)
            offset = self.offset_mgr.get_offset()
            if offset is None and self.offset_mgr.mappings:
                offset = 0
            elif offset is None:
                offset = 0
            for i, entry in enumerate(target_entries):
                actual = entry.page + offset if not entry.is_extra else entry.actual_page
                page_idx = max(0, min(actual - 1, doc.page_count - 1))
                page = doc.load_page(page_idx)
                pix = page.get_pixmap(dpi=100)
                img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
                img_tk = ImageTk.PhotoImage(img)
                label_frame = tk.Frame(inner)
                tk.Label(label_frame, text=f"条目: {entry.title}", font=('Arial', 10, 'bold')).pack()
                tk.Label(label_frame, text=f"目录页码: {entry.page}  实际页码: {actual}").pack()
                lbl_img = tk.Label(label_frame, image=img_tk)
                lbl_img.image = img_tk
                lbl_img.pack()
                label_frame.pack(pady=10)
            doc.close()
        except Exception as e:
            messagebox.showerror("错误", f"预览失败: {str(e)}")
        inner.update_idletasks()
        canvas.config(scrollregion=canvas.bbox("all"))

    def show_prompt(self):
        prompt_win = tk.Toplevel(self.root)
        prompt_win.title("LLM OCR 提示词")
        text = tk.Text(prompt_win, wrap=tk.WORD, width=70, height=15)
        text.insert(1.0, LLM_PROMPT)
        text.pack(padx=10, pady=10)
        def copy_to_clipboard():
            self.root.clipboard_clear()
            self.root.clipboard_append(LLM_PROMPT)
            # messagebox.showinfo("已复制", "提示词已复制到剪贴板")
        tk.Button(prompt_win, text="复制到剪贴板", command=copy_to_clipboard).pack(pady=5)

    def show_terminology(self):
        term_win = tk.Toplevel(self.root)
        term_win.title("软件术语解释")
        text = tk.Text(term_win, wrap=tk.WORD, width=80, height=10)
        text.insert(1.0, TERMINOLOGY)
        # 配置加粗标签
        text.tag_configure("bold", font=("TkDefaultFont", 10, "bold"))
        
        # 按行处理,为词条名加粗
        lines = TERMINOLOGY.splitlines()
        row = 1
        for line in lines:
            if not line.strip():
                row += 1
                continue
            if line.startswith("【"):
                # text.tag_add("bold", f"{row}.0", f"{row}.end")
                pass
            else:
                # 查找分隔符位置,支持中文冒号、英文冒号、等号
                for sep in (':', ':', '='):
                    idx = line.find(sep)
                    if idx != -1:
                        text.tag_add("bold", f"{row}.0", f"{row}.{idx}")
                        break
            row += 1
        
        text.config(state=tk.DISABLED)
        text.pack(padx=10, pady=10)


class EditEntryDialog:
    def __init__(self, parent, title, page, level, max_page):
        self.top = tk.Toplevel(parent)
        self.top.title("编辑条目")
        self.result = None
        tk.Label(self.top, text="标题:").grid(row=0, column=0, padx=5, pady=5, sticky='w')
        self.title_var = tk.StringVar(value=title)
        tk.Entry(self.top, textvariable=self.title_var, width=30).grid(row=0, column=1, padx=5, pady=5)
        tk.Label(self.top, text="页码:").grid(row=1, column=0, padx=5, pady=5, sticky='w')
        self.page_var = tk.IntVar(value=page)
        tk.Spinbox(self.top, from_=1, to=max_page or 9999, textvariable=self.page_var, width=8).grid(row=1, column=1, padx=5, pady=5, sticky='w')
        tk.Label(self.top, text="层级:").grid(row=2, column=0, padx=5, pady=5, sticky='w')
        self.level_var = tk.IntVar(value=level)
        tk.Spinbox(self.top, from_=1, to=5, textvariable=self.level_var, width=5).grid(row=2, column=1, padx=5, pady=5, sticky='w')
        btn_frame = tk.Frame(self.top)
        tk.Button(btn_frame, text="确定", command=self.on_ok).pack(side=tk.LEFT, padx=5)
        tk.Button(btn_frame, text="取消", command=self.top.destroy).pack(side=tk.LEFT, padx=5)
        btn_frame.grid(row=3, column=0, columnspan=2, pady=10)

    def on_ok(self):
        self.result = (self.title_var.get().strip(), self.page_var.get(), self.level_var.get())
        self.top.destroy()


if __name__ == "__main__":
    if DND_AVAILABLE:
        root = TkinterDnD.Tk()
    else:
        root = tk.Tk()
    root.title("PDF大纲编辑器 Pro")
    root.geometry("900x700")
    app = PDFOutlineEditor(root)
    root.mainloop()



















免费评分

参与人数 36吾爱币 +36 热心值 +29 收起 理由
斜雨孤泪 + 1 我很赞同!
towy007 + 1 + 1 谢谢@Thanks!
grrr_zhao + 1 + 1 谢谢@Thanks!
开心熊猫741 + 1 + 1 我很赞同!
Tyng123 + 1 谢谢@Thanks!
sw7057 + 1 + 1 我很赞同!
忆江南 + 1 + 1 我很赞同!
smallchop + 1 + 1 热心回复!
固相膜 + 1 + 1 谢谢@Thanks!
Char007 + 1 用心讨论,共获提升!
Bflgh + 1 + 1 我很赞同!
wangzhizhuo + 1 + 1 谢谢@Thanks!
QaQ355 + 1 + 1 热心回复!
weidechan + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
fusonni + 1 我很赞同!
_达圣 + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
猪啃菠萝 + 1 我很赞同!
SGLenyun + 1 + 1 谢谢@Thanks!
jikic + 1 + 1 谢谢@Thanks!
xunxunmimi0936 + 1 热心回复!
ice52 + 1 我很赞同!
laozhang4201 + 1 + 1 热心回复!
papapo + 1 + 1 我很赞同!
tobyjsh + 1 + 1 我很赞同!
halking + 1 + 1 我很赞同!
woyaodl + 1 + 1 我很赞同!
wYw729 + 1 + 1 从未想过的应用方向
o97 + 1 热心回复!
luisls + 1 我很赞同!
wlmhit903 + 1 开发不易,鼓励!
yanglinman + 1 谢谢@Thanks!
dy20181111 + 1 + 1 我很赞同!
苏紫方璇 + 7 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
pythonfun + 1 + 1 我很赞同!
lyslt9566 + 1 谢谢@Thanks!
yulai3230 + 1 + 1 我很赞同!

查看全部评分

本帖被以下淘专辑推荐:

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

wufashihu 发表于 2026-6-14 13:47
cayuer 发表于 2026-6-14 13:27
感谢分享 不知道有没成品

https://wwaye.lanzoue.com/iz1Ca3rugf7e

帮你打包好了,解压就行

免费评分

参与人数 11吾爱币 +10 热心值 +8 收起 理由
liguibin + 1 + 1 用心讨论,共获提升!
and11 + 1 + 1 谢谢@Thanks!
fusonni + 1 热心回复!
leskady + 1 谢谢@Thanks!
木木有森林 + 1 谢谢@Thanks!
ytkxw + 1 + 1 谢谢@Thanks!
o97 + 1 热心回复!
Bao_lai + 1 谢谢@Thanks!
yanglinman + 1 + 1 谢谢@Thanks!
naixubao + 1 + 1 谢谢@Thanks!
hongye0 + 2 + 1 热心回复!

查看全部评分

lzspain 发表于 2026-6-14 12:04
如果PDF文件是矢量的,且文本可编辑,acrobat插件AutoBookmark、金山PDF、foxitPDF等都可以自动识别并添加书签;如果文件是图片型,将目录ocr后,可以用quick outline、PdgCntEditor等软件快速添加,原理和楼主这个类似。
 楼主| pyjiujiu 发表于 2026-6-14 15:38
lzspain 发表于 2026-6-14 12:04
如果PDF文件是矢量的,且文本可编辑,acrobat插件AutoBookmark、金山PDF、foxitPDF等都可以自动识别并添加 ...

谢分享知识,欢迎讨论
本软件 主要针对 扫描版书籍(往往缺乏目录),其他文件 往往都已经带目录,
其次 OCR 原理虽简单,可也一般操作细节很多,格式易出错,计算页码偏移,最关键的 效率很低,本软件+多模态LLM,效率可以非常高 (未来准备加 API 的支持,选中 pdf 目录页的页码,一步生成)

@xinanxiaodong
老马的 PdgCntEditor 之前一直用,操作过于复杂,且对扫描版书 OCR 效率提升没帮助

@wufashihu
谢打包

@maomaochong
mac嘛,暂时做不到,有机会 就挑战下

cayuer 发表于 2026-6-14 13:27
感谢分享 不知道有没成品
yulai3230 发表于 2026-6-14 11:56
感谢大佬,挺有用的
xinanxiaodong 发表于 2026-6-14 13:09
感谢分享。不知楼主是否用过老马的PdgCntEditor?
maomaochong 发表于 2026-6-14 13:48
小白问一下:支持mac嘛?
FY999 发表于 2026-6-14 15:03
太好了,希望成品尽快问世!
 楼主| pyjiujiu 发表于 2026-6-14 15:20
补充:提示词 prompt,已经集成在软件中,点出来 复制给 LLM 即可
具体位置:菜单栏 -> 帮助 -> LLM OCR提示词

这里也分享,大模型 OCR 的 具体用例:(供大家参考,调用是 Qwen3-Omni-Flash)
chat.qwen.ai/s/t_1c3dc189-7105-4958-a02e-a8a54eef9cd2
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-7-10 18:59

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表