-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathPDF_大纲编辑器.py
More file actions
1387 lines (1228 loc) · 57.8 KB
/
Copy pathPDF_大纲编辑器.py
File metadata and controls
1387 lines (1228 loc) · 57.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
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="展开额外条目 ▸", 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="展开额外条目 ▸")
else:
self.extra_frame_container.pack(pady=5)
self.toggle_btn.config(text="折叠额外条目 ▾")
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):