pdflinkcheck 1.1.94__py3-none-any.whl → 1.2.29__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pdflinkcheck/__init__.py +88 -18
- pdflinkcheck/__main__.py +6 -0
- pdflinkcheck/analysis_pdfium.py +131 -0
- pdflinkcheck/{analyze_pymupdf.py → analysis_pymupdf.py} +99 -141
- pdflinkcheck/{analyze_pypdf.py → analysis_pypdf.py} +51 -39
- pdflinkcheck/cli.py +52 -48
- pdflinkcheck/data/LICENSE +18 -15
- pdflinkcheck/data/README.md +23 -25
- pdflinkcheck/data/pyproject.toml +17 -26
- pdflinkcheck/datacopy.py +16 -1
- pdflinkcheck/dev.py +2 -2
- pdflinkcheck/environment.py +14 -2
- pdflinkcheck/gui.py +346 -563
- pdflinkcheck/helpers.py +88 -0
- pdflinkcheck/io.py +24 -6
- pdflinkcheck/report.py +598 -97
- pdflinkcheck/security.py +189 -0
- pdflinkcheck/splash.py +38 -0
- pdflinkcheck/stdlib_server.py +7 -21
- pdflinkcheck/stdlib_server_alt.py +571 -0
- pdflinkcheck/tk_utils.py +188 -0
- pdflinkcheck/update_msix_version.py +2 -0
- pdflinkcheck/validate.py +104 -170
- pdflinkcheck/version_info.py +2 -2
- {pdflinkcheck-1.1.94.dist-info → pdflinkcheck-1.2.29.dist-info}/METADATA +41 -40
- {pdflinkcheck-1.1.94.dist-info → pdflinkcheck-1.2.29.dist-info}/RECORD +34 -27
- pdflinkcheck-1.2.29.dist-info/WHEEL +5 -0
- {pdflinkcheck-1.1.94.dist-info → pdflinkcheck-1.2.29.dist-info}/entry_points.txt +0 -1
- pdflinkcheck-1.2.29.dist-info/licenses/LICENSE +27 -0
- pdflinkcheck-1.2.29.dist-info/top_level.txt +1 -0
- pdflinkcheck/analyze_pypdf_v2.py +0 -217
- pdflinkcheck-1.1.94.dist-info/WHEEL +0 -4
- pdflinkcheck-1.1.94.dist-info/licenses/LICENSE +0 -24
- {pdflinkcheck-1.1.94.dist-info → pdflinkcheck-1.2.29.dist-info}/licenses/LICENSE-AGPL3 +0 -0
- {pdflinkcheck-1.1.94.dist-info → pdflinkcheck-1.2.29.dist-info}/licenses/LICENSE-MIT +0 -0
pdflinkcheck/gui.py
CHANGED
|
@@ -1,23 +1,24 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
2
|
# SPDX-License-Identifier: MIT
|
|
3
3
|
# src/pdflinkcheck/gui.py
|
|
4
|
+
from __future__ import annotations
|
|
4
5
|
import tkinter as tk
|
|
5
|
-
from tkinter import filedialog, ttk, messagebox
|
|
6
|
+
from tkinter import filedialog, ttk, messagebox, PhotoImage
|
|
6
7
|
import sys
|
|
7
8
|
from pathlib import Path
|
|
8
|
-
from typing import Optional
|
|
9
|
+
from typing import Optional
|
|
9
10
|
import unicodedata
|
|
10
11
|
from importlib.resources import files
|
|
11
12
|
import pyhabitat
|
|
12
13
|
import ctypes
|
|
14
|
+
import threading
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
# Import the core analysis function
|
|
16
|
+
# --- Core Imports ---
|
|
17
17
|
from pdflinkcheck.report import run_report_and_call_exports
|
|
18
18
|
from pdflinkcheck.version_info import get_version_from_pyproject
|
|
19
19
|
from pdflinkcheck.io import get_first_pdf_in_cwd, get_friendly_path, PDFLINKCHECK_HOME
|
|
20
|
-
from pdflinkcheck.environment import pymupdf_is_available, clear_all_caches, is_in_git_repo
|
|
20
|
+
from pdflinkcheck.environment import pymupdf_is_available, pdfium_is_available, clear_all_caches, is_in_git_repo
|
|
21
|
+
from pdflinkcheck.tk_utils import center_window_on_primary
|
|
21
22
|
|
|
22
23
|
class RedirectText:
|
|
23
24
|
"""A class to redirect sys.stdout messages to a Tkinter Text widget."""
|
|
@@ -25,455 +26,206 @@ class RedirectText:
|
|
|
25
26
|
self.text_widget = text_widget
|
|
26
27
|
|
|
27
28
|
def write(self, string):
|
|
28
|
-
"""Insert the incoming string into the Text widget."""
|
|
29
29
|
self.text_widget.insert(tk.END, string)
|
|
30
|
-
self.text_widget.see(tk.END)
|
|
31
|
-
self.text_widget.update_idletasks()
|
|
30
|
+
self.text_widget.see(tk.END)
|
|
31
|
+
self.text_widget.update_idletasks()
|
|
32
32
|
|
|
33
33
|
def flush(self, *args):
|
|
34
|
-
"""Required for file-like objects, but does nothing here."""
|
|
35
34
|
pass
|
|
36
35
|
|
|
36
|
+
class PDFLinkCheckApp:
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
# --- Lifecycle & Initialization ---
|
|
39
39
|
|
|
40
|
-
def
|
|
41
|
-
|
|
42
|
-
# Path to pdflinkcheck/data/themes/forest/
|
|
43
|
-
theme_dir = files("pdflinkcheck.data.themes.forest")
|
|
44
|
-
# Load the theme files
|
|
45
|
-
self.tk.call("source", str(theme_dir / f"forest-light.tcl"))
|
|
46
|
-
self.tk.call("source", str(theme_dir / f"forest-dark.tcl"))
|
|
40
|
+
def __init__(self, root: tk.Tk):
|
|
41
|
+
self.root = root
|
|
47
42
|
|
|
48
|
-
|
|
43
|
+
# Do NOT load theme yet.
|
|
44
|
+
# Run the "heavy" initialization first
|
|
45
|
+
self._initialize_vars()
|
|
49
46
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"""
|
|
53
|
-
Calls light/dark toggle for the forest theme with self._toggle_theme_just_forest()
|
|
54
|
-
"""
|
|
55
|
-
return self._toggle_theme_forest()
|
|
56
|
-
|
|
57
|
-
def _toggle_theme_forest(self):
|
|
58
|
-
if ttk.Style().theme_use() == "forest-light":
|
|
59
|
-
ttk.Style().theme_use("forest-dark")
|
|
60
|
-
elif ttk.Style().theme_use() == "forest-dark":
|
|
61
|
-
ttk.Style().theme_use("forest-light")
|
|
47
|
+
# NOW load the theme (this takes ~100-300ms)
|
|
48
|
+
self._initialize_forest_theme()
|
|
62
49
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
#
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def __init__(self):
|
|
74
|
-
super().__init__()
|
|
50
|
+
# Apply the theme
|
|
51
|
+
style = ttk.Style()
|
|
52
|
+
style.configure(".", padding=2) # global min padding
|
|
53
|
+
style.configure("TFrame", padding=2)
|
|
54
|
+
style.configure("TLabelFrame", padding=(4,2))
|
|
55
|
+
style.configure("TButton", padding=4)
|
|
56
|
+
style.configure("TCheckbutton", padding=2)
|
|
57
|
+
style.configure("TRadiobutton", padding=2)
|
|
58
|
+
style.theme_use("forest-dark")
|
|
75
59
|
|
|
76
|
-
|
|
77
|
-
self.
|
|
78
|
-
|
|
79
|
-
ttk.Style().theme_use("forest-dark") # but if you use _toggle_theme_just_forest(), then you had better do this
|
|
80
|
-
|
|
81
|
-
if is_in_git_repo() and not pyhabitat.as_pyinstaller() and not pyhabitat.is_pyz():
|
|
82
|
-
# Checking for PYZ is overkill, because a PYZ s not expected to carry a .git directory, which is a check that is already completed.
|
|
83
|
-
title_suffix = ""# " [Development]"
|
|
84
|
-
else:
|
|
85
|
-
title_suffix = ""
|
|
86
|
-
|
|
87
|
-
self.title(f"PDF Link Check v{get_version_from_pyproject()}{title_suffix}")
|
|
88
|
-
self.geometry("800x600")
|
|
60
|
+
self.root.title(f"PDF Link Check v{get_version_from_pyproject()}") # Short title
|
|
61
|
+
self.root.geometry("700x500") # Smaller starting size
|
|
62
|
+
self.root.minsize(600, 400) # Prevent too-small window
|
|
89
63
|
|
|
90
64
|
self._set_icon()
|
|
91
65
|
|
|
92
|
-
|
|
93
|
-
|
|
66
|
+
# --- 2. Widget Construction ---
|
|
67
|
+
self._create_widgets()
|
|
68
|
+
self._initialize_menubar()
|
|
69
|
+
|
|
70
|
+
def _initialize_vars(self):
|
|
71
|
+
"""Logic that takes time but doesn't need a UI yet."""
|
|
72
|
+
|
|
73
|
+
# --- 1. Variable State Management ---
|
|
94
74
|
self.pdf_path = tk.StringVar(value="")
|
|
95
|
-
self.pdf_library_var = tk.StringVar(value="
|
|
96
|
-
self.
|
|
97
|
-
self.
|
|
98
|
-
self.do_export_report_json_var = tk.BooleanVar(value=True)
|
|
99
|
-
self.do_export_report_txt_var = tk.BooleanVar(value=True)
|
|
75
|
+
self.pdf_library_var = tk.StringVar(value="pymupdf")
|
|
76
|
+
self.do_export_report_json_var = tk.BooleanVar(value=True)
|
|
77
|
+
self.do_export_report_txt_var = tk.BooleanVar(value=True)
|
|
100
78
|
self.current_report_text = None
|
|
101
79
|
self.current_report_data = None
|
|
102
80
|
|
|
103
|
-
|
|
104
|
-
self.
|
|
81
|
+
# Track exported file paths
|
|
82
|
+
self.last_json_path: Optional[Path] = None
|
|
83
|
+
self.last_txt_path: Optional[Path] = None
|
|
105
84
|
|
|
85
|
+
# Engine detection (This can take a few ms)
|
|
86
|
+
self.pdf_library_var = tk.StringVar(value="PDFium")
|
|
87
|
+
if not pdfium_is_available():
|
|
88
|
+
self.pdf_library_var.set("PyMuPDF")
|
|
106
89
|
if not pymupdf_is_available():
|
|
107
|
-
print(f"pymupdf_is_available: {pymupdf_is_available()}")
|
|
108
90
|
self.pdf_library_var.set("pypdf")
|
|
109
91
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
self.
|
|
116
|
-
self._toggle_json_export()
|
|
117
|
-
self._toggle_txt_export()
|
|
92
|
+
|
|
93
|
+
# --- Theme & Visual Initialization ---
|
|
94
|
+
def _initialize_forest_theme(self):
|
|
95
|
+
theme_dir = files("pdflinkcheck.data.themes.forest")
|
|
96
|
+
self.root.tk.call("source", str(theme_dir / "forest-light.tcl"))
|
|
97
|
+
self.root.tk.call("source", str(theme_dir / "forest-dark.tcl"))
|
|
118
98
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
99
|
+
def _toggle_theme(self):
|
|
100
|
+
style = ttk.Style(self.root) # Explicitly link style to our root
|
|
101
|
+
if style.theme_use() == "forest-light":
|
|
102
|
+
style.theme_use("forest-dark")
|
|
103
|
+
elif style.theme_use() == "forest-dark":
|
|
104
|
+
style.theme_use("forest-light")
|
|
105
|
+
|
|
106
|
+
def _set_icon(self):
|
|
107
|
+
icon_dir = files("pdflinkcheck.data.icons")
|
|
108
|
+
try:
|
|
109
|
+
png_path = icon_dir.joinpath("Logo-150x150.png")
|
|
110
|
+
if png_path.exists():
|
|
111
|
+
self.icon_img = PhotoImage(file=str(png_path))
|
|
112
|
+
self.root.iconphoto(True, self.icon_img)
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
try:
|
|
116
|
+
icon_path = icon_dir.joinpath("red_pdf_512px.ico")
|
|
117
|
+
if icon_path.exists():
|
|
118
|
+
self.root.iconbitmap(str(icon_path))
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _initialize_menubar(self):
|
|
124
|
+
"""Builds the application menu bar."""
|
|
125
|
+
menubar = tk.Menu(self.root)
|
|
126
|
+
self.root.config(menu=menubar)
|
|
122
127
|
|
|
123
|
-
# Tools menu
|
|
124
128
|
tools_menu = tk.Menu(menubar, tearoff=0)
|
|
125
129
|
menubar.add_cascade(label="Tools", menu=tools_menu)
|
|
126
130
|
|
|
127
131
|
tools_menu.add_command(label="Toggle Theme", command=self._toggle_theme)
|
|
132
|
+
tools_menu.add_command(label="Clear Output Window", command=self._clear_output_window)
|
|
133
|
+
tools_menu.add_command(label="Copy Output to Clipboard", command=self._copy_output_to_clipboard)
|
|
128
134
|
tools_menu.add_command(label="Clear Cache", command=self._clear_all_caches)
|
|
129
135
|
|
|
130
|
-
# Add existing License/Readme to tools menu
|
|
131
136
|
tools_menu.add_separator()
|
|
132
137
|
tools_menu.add_command(label="License", command=self._show_license)
|
|
133
138
|
tools_menu.add_command(label="Readme", command=self._show_readme)
|
|
134
139
|
tools_menu.add_command(label="I Have Questions", command=self._show_i_have_questions)
|
|
135
|
-
# In class PDFLinkCheckerApp:
|
|
136
140
|
|
|
137
|
-
|
|
138
|
-
"""Copies the current PDF path from the Entry widget to the system clipboard."""
|
|
139
|
-
path_to_copy = self.pdf_path.get()
|
|
140
|
-
|
|
141
|
-
if path_to_copy:
|
|
142
|
-
try:
|
|
143
|
-
# Clear the clipboard
|
|
144
|
-
self.clipboard_clear()
|
|
145
|
-
# Append the path string to the clipboard
|
|
146
|
-
self.clipboard_append(path_to_copy)
|
|
147
|
-
# Notify the user (optional, but good UX)
|
|
148
|
-
messagebox.showinfo("Copied", "PDF Path copied to clipboard.")
|
|
149
|
-
except tk.TclError as e:
|
|
150
|
-
# Handle cases where clipboard access might be blocked
|
|
151
|
-
messagebox.showerror("Copy Error", f"Failed to access the system clipboard: {e}")
|
|
152
|
-
else:
|
|
153
|
-
messagebox.showwarning("Copy Failed", "The PDF Path field is empty.")
|
|
154
|
-
|
|
155
|
-
def _scroll_to_top(self):
|
|
156
|
-
"""Scrolls the output text widget to the top."""
|
|
157
|
-
self.output_text.see('1.0') # '1.0' is the index for the very first character
|
|
141
|
+
# --- UI Component Building ---
|
|
158
142
|
|
|
159
|
-
def
|
|
160
|
-
"""
|
|
161
|
-
self.output_text.see(tk.END) # tk.END is the index for the position just after the last character
|
|
143
|
+
def _create_widgets(self):
|
|
144
|
+
"""Compact layout with reduced padding."""
|
|
162
145
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
messagebox.showinfo("Caches Cleared", f"All caches have been cleared.\nPyMuPDF available: {pymupdf_is_available()}")
|
|
146
|
+
# --- Control Frame (Top) ---
|
|
147
|
+
control_frame = ttk.Frame(self.root, padding=(4, 2, 4, 2))
|
|
148
|
+
control_frame.pack(fill='x', pady=(2, 2))
|
|
167
149
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
"""
|
|
172
|
-
try:
|
|
173
|
-
# Use the Traversable object's read_text() method.
|
|
174
|
-
# This handles files located inside zip archives (.pyz, pipx venvs) correctly.
|
|
175
|
-
license_path_traversable = files("pdflinkcheck.data") / "LICENSE"
|
|
176
|
-
license_content = license_path_traversable.read_text(encoding="utf-8")
|
|
177
|
-
|
|
178
|
-
except FileNotFoundError:
|
|
179
|
-
if is_in_git_repo():
|
|
180
|
-
messagebox.showinfo(
|
|
181
|
-
"Local Development Mode",
|
|
182
|
-
"Embedded data files not found – copying from root..."
|
|
183
|
-
)
|
|
184
|
-
try:
|
|
185
|
-
from pdflinkcheck.datacopy import ensure_package_license, ensure_data_files_for_build
|
|
186
|
-
#ensure_package_license()
|
|
187
|
-
ensure_data_files_for_build()
|
|
188
|
-
# Retry display
|
|
189
|
-
self._show_license()
|
|
190
|
-
return
|
|
191
|
-
except Exception as e:
|
|
192
|
-
messagebox.showerror("Copy Failed", f"Could not copy file: {e}")
|
|
193
|
-
else:
|
|
194
|
-
messagebox.showerror(
|
|
195
|
-
"Packaging Error",
|
|
196
|
-
"Embedded file not found. This indicates a problem with the package build/installation."
|
|
197
|
-
)
|
|
198
|
-
return
|
|
199
|
-
|
|
200
|
-
except Exception as e:
|
|
201
|
-
messagebox.showerror("Read Error", f"Failed to read embedded LICENSE file: {e}")
|
|
202
|
-
return
|
|
150
|
+
# === Row 0: File Selection ===
|
|
151
|
+
file_selection_frame = ttk.Frame(control_frame)
|
|
152
|
+
file_selection_frame.grid(row=0, column=0, columnspan=3, padx=0, pady=(2, 4), sticky='ew')
|
|
203
153
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
# Text widget for content
|
|
210
|
-
text_widget = tk.Text(license_window, wrap=tk.WORD, font=('Monospace', 10), padx=10, pady=10)
|
|
211
|
-
text_widget.insert(tk.END, license_content)
|
|
212
|
-
text_widget.config(state=tk.DISABLED)
|
|
213
|
-
|
|
214
|
-
# Scrollbar
|
|
215
|
-
scrollbar = ttk.Scrollbar(license_window, command=text_widget.yview)
|
|
216
|
-
text_widget['yscrollcommand'] = scrollbar.set
|
|
217
|
-
|
|
218
|
-
# Layout
|
|
219
|
-
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
220
|
-
text_widget.pack(fill='both', expand=True)
|
|
221
|
-
|
|
222
|
-
# Make the window modal (optional, but good practice for notices)
|
|
223
|
-
license_window.transient(self)
|
|
224
|
-
license_window.grab_set()
|
|
225
|
-
self.wait_window(license_window)
|
|
154
|
+
ttk.Label(file_selection_frame, text="PDF Path:").pack(side=tk.LEFT, padx=(0, 3))
|
|
155
|
+
entry = ttk.Entry(file_selection_frame, textvariable=self.pdf_path)
|
|
156
|
+
entry.pack(side=tk.LEFT, fill='x', expand=True, padx=3)
|
|
157
|
+
ttk.Button(file_selection_frame, text="Browse...", command=self._select_pdf, width=10).pack(side=tk.LEFT, padx=(3, 3))
|
|
158
|
+
ttk.Button(file_selection_frame, text="Copy Path", command=self._copy_pdf_path, width=10).pack(side=tk.LEFT, padx=(0, 0))
|
|
226
159
|
|
|
227
|
-
|
|
228
|
-
""
|
|
229
|
-
|
|
230
|
-
"""
|
|
231
|
-
try:
|
|
232
|
-
# Use the Traversable object's read_text() method.
|
|
233
|
-
# This handles files located inside zip archives (.pyz, pipx venvs) correctly.
|
|
234
|
-
readme_path_traversable = files("pdflinkcheck.data") / "README.md"
|
|
235
|
-
readme_content = readme_path_traversable.read_text(encoding="utf-8")
|
|
236
|
-
readme_content = sanitize_glyphs_for_tkinter(readme_content)
|
|
237
|
-
except FileNotFoundError:
|
|
238
|
-
if is_in_git_repo():
|
|
239
|
-
messagebox.showinfo(
|
|
240
|
-
"Local Development Mode",
|
|
241
|
-
"Embedded data files not found – copying from root..."
|
|
242
|
-
)
|
|
243
|
-
try:
|
|
244
|
-
from pdflinkcheck.datacopy import ensure_package_readme, ensure_data_files_for_build
|
|
245
|
-
#ensure_package_readme()
|
|
246
|
-
ensure_data_files_for_build()
|
|
247
|
-
# Retry display
|
|
248
|
-
self._show_readme()
|
|
249
|
-
return
|
|
250
|
-
except Exception as e:
|
|
251
|
-
messagebox.showerror("Copy Failed", f"Could not copy file: {e}")
|
|
252
|
-
else:
|
|
253
|
-
messagebox.showerror(
|
|
254
|
-
"Packaging Error",
|
|
255
|
-
"Embedded file not found. This indicates a problem with the package build/installation."
|
|
256
|
-
)
|
|
257
|
-
return
|
|
258
|
-
except Exception as e:
|
|
259
|
-
messagebox.showerror("Read Error", f"Failed to read embedded README.md file: {e}")
|
|
260
|
-
return
|
|
160
|
+
# === Row 1: Configuration & Export Jumps ===
|
|
161
|
+
pdf_library_frame = ttk.LabelFrame(control_frame, text="Backend Engine:")
|
|
162
|
+
pdf_library_frame.grid(row=1, column=0, padx=3, pady=3, sticky='nsew')
|
|
261
163
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
# Text widget for content
|
|
268
|
-
text_widget = tk.Text(readme_window, wrap=tk.WORD, font=('Monospace', 10), padx=10, pady=10)
|
|
269
|
-
text_widget.insert(tk.END, readme_content)
|
|
270
|
-
text_widget.config(state=tk.DISABLED)
|
|
271
|
-
|
|
272
|
-
# Scrollbar
|
|
273
|
-
scrollbar = ttk.Scrollbar(readme_window, command=text_widget.yview)
|
|
274
|
-
text_widget['yscrollcommand'] = scrollbar.set
|
|
275
|
-
|
|
276
|
-
# Layout
|
|
277
|
-
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
278
|
-
text_widget.pack(fill='both', expand=True)
|
|
279
|
-
|
|
280
|
-
# Make the window modal (optional, but good practice for notices)
|
|
281
|
-
readme_window.transient(self)
|
|
282
|
-
readme_window.grab_set()
|
|
283
|
-
self.wait_window(readme_window)
|
|
164
|
+
if pdfium_is_available():
|
|
165
|
+
ttk.Radiobutton(pdf_library_frame, text="PDFium", variable=self.pdf_library_var, value="PDFium").pack(side='left', padx=5, pady=1)
|
|
166
|
+
if pymupdf_is_available():
|
|
167
|
+
ttk.Radiobutton(pdf_library_frame, text="PyMuPDF", variable=self.pdf_library_var, value="PyMuPDF").pack(side='left', padx=3, pady=1)
|
|
168
|
+
ttk.Radiobutton(pdf_library_frame, text="pypdf", variable=self.pdf_library_var, value="pypdf").pack(side='left', padx=3, pady=1)
|
|
284
169
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
Reads the embedded I Have Questions.md file and displays its content in a new modal window.
|
|
288
|
-
"""
|
|
289
|
-
try:
|
|
290
|
-
# Use the Traversable object's read_text() method.
|
|
291
|
-
# This handles files located inside zip archives (.pyz, pipx venvs) correctly.
|
|
292
|
-
i_have_questions_path_traversable = files("pdflinkcheck.data") / "I Have Questions.md"
|
|
293
|
-
i_have_questions_content = i_have_questions_path_traversable.read_text(encoding="utf-8")
|
|
294
|
-
i_have_questions_content = sanitize_glyphs_for_tkinter(i_have_questions_content)
|
|
295
|
-
except FileNotFoundError:
|
|
296
|
-
messagebox.showerror("Read Error", f"Failed to read embedded 'I Have Questions.md' file.")
|
|
297
|
-
return
|
|
170
|
+
export_config_frame = ttk.LabelFrame(control_frame, text="Export Enabled:")
|
|
171
|
+
export_config_frame.grid(row=1, column=1, padx=3, pady=3, sticky='nsew')
|
|
298
172
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
i_have_questions_window.title("I Have Questions.md")
|
|
302
|
-
i_have_questions_window.geometry("600x400")
|
|
303
|
-
|
|
304
|
-
# Text widget for content
|
|
305
|
-
text_widget = tk.Text(i_have_questions_window, wrap=tk.WORD, font=('Monospace', 10), padx=10, pady=10)
|
|
306
|
-
text_widget.insert(tk.END, i_have_questions_content)
|
|
307
|
-
text_widget.config(state=tk.DISABLED)
|
|
308
|
-
|
|
309
|
-
# Scrollbar
|
|
310
|
-
scrollbar = ttk.Scrollbar(i_have_questions_window, command=text_widget.yview)
|
|
311
|
-
text_widget['yscrollcommand'] = scrollbar.set
|
|
312
|
-
|
|
313
|
-
# Layout
|
|
314
|
-
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
315
|
-
text_widget.pack(fill='both', expand=True)
|
|
316
|
-
|
|
317
|
-
# Make the window modal (optional, but good practice for notices)
|
|
318
|
-
i_have_questions_window.transient(self)
|
|
319
|
-
i_have_questions_window.grab_set()
|
|
320
|
-
self.wait_window(i_have_questions_window)
|
|
173
|
+
ttk.Checkbutton(export_config_frame, text="JSON", variable=self.do_export_report_json_var).pack(side=tk.LEFT, padx=4)
|
|
174
|
+
ttk.Checkbutton(export_config_frame, text="TXT", variable=self.do_export_report_txt_var).pack(side=tk.LEFT, padx=4)
|
|
321
175
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
control_frame = ttk.Frame(self, padding="10")
|
|
325
|
-
control_frame.pack(fill='x')
|
|
176
|
+
self.export_actions_frame = ttk.LabelFrame(control_frame, text="Open Report Files:")
|
|
177
|
+
self.export_actions_frame.grid(row=1, column=2, padx=3, pady=3, sticky='nsew')
|
|
326
178
|
|
|
327
|
-
|
|
179
|
+
self.btn_open_json = ttk.Button(self.export_actions_frame, text="Open JSON", command=lambda: self._open_export_file("json"), width=10)
|
|
180
|
+
self.btn_open_json.pack(side=tk.LEFT, padx=3, pady=1)
|
|
328
181
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
file_selection_frame.grid(row=0, column=0, columnspan=3, padx=0, pady=5, sticky='ew')
|
|
332
|
-
|
|
333
|
-
# Elements are now packed/gridded within file_selection_frame
|
|
334
|
-
|
|
335
|
-
# Label
|
|
336
|
-
ttk.Label(file_selection_frame, text="PDF Path:").pack(side=tk.LEFT, padx=(0, 5))
|
|
337
|
-
|
|
338
|
-
# Entry (Path Display)
|
|
339
|
-
ttk.Entry(file_selection_frame, textvariable=self.pdf_path, width=50).pack(side=tk.LEFT, fill='x', expand=True, padx=5)
|
|
340
|
-
# The Entry field (column 1) must expand horizontally within its frame
|
|
341
|
-
# Since we are using PACK for this frame, we use fill='x', expand=True on the Entry.
|
|
342
|
-
|
|
343
|
-
# Browse Button
|
|
344
|
-
ttk.Button(file_selection_frame, text="Browse...", command=self._select_pdf).pack(side=tk.LEFT, padx=(5, 5))
|
|
182
|
+
self.btn_open_txt = ttk.Button(self.export_actions_frame, text="Open TXT", command=lambda: self._open_export_file("txt"), width=10)
|
|
183
|
+
self.btn_open_txt.pack(side=tk.LEFT, padx=3, pady=1)
|
|
345
184
|
|
|
346
|
-
#
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
report_brevity_frame.grid(row=1, column=0, padx=5, pady=5, sticky='nsew')
|
|
356
|
-
#
|
|
357
|
-
ttk.Checkbutton(
|
|
358
|
-
report_brevity_frame,
|
|
359
|
-
text="Show All Links.",
|
|
360
|
-
variable=self.show_all_links_var,
|
|
361
|
-
command=self._toggle_max_links_entry
|
|
362
|
-
).pack(side='left', padx=5, pady=1)
|
|
363
|
-
|
|
364
|
-
ttk.Label(report_brevity_frame, text="Max Links to Display:").pack(side='left', padx=5, pady=1)
|
|
365
|
-
self.max_links_entry = ttk.Entry(report_brevity_frame, textvariable=self.max_links_var, width=7)
|
|
366
|
-
self.max_links_entry.pack(side='left', padx=5, pady=5)
|
|
367
|
-
|
|
368
|
-
# --- PDF Library Selection ---
|
|
369
|
-
# Create a labeled group for the PDF options
|
|
370
|
-
pdf_library_frame = ttk.LabelFrame(control_frame, text="Select PDF Library:")
|
|
371
|
-
pdf_library_frame.grid(row=1, column=1, padx=5, pady=5, sticky='nsew')
|
|
372
|
-
|
|
373
|
-
# Radio options inside the frame
|
|
374
|
-
ttk.Radiobutton(
|
|
375
|
-
pdf_library_frame,
|
|
376
|
-
text="PyMuPDF",
|
|
377
|
-
variable=self.pdf_library_var,
|
|
378
|
-
value="PyMuPDF",
|
|
379
|
-
|
|
380
|
-
).pack(side='left', padx=5, pady=1)
|
|
381
|
-
|
|
382
|
-
ttk.Radiobutton(
|
|
383
|
-
pdf_library_frame,
|
|
384
|
-
text="pypdf",
|
|
385
|
-
variable=self.pdf_library_var,
|
|
386
|
-
value="pypdf",
|
|
387
|
-
).pack(side='left', padx=5, pady=1)
|
|
388
|
-
|
|
389
|
-
export_group_frame = ttk.LabelFrame(control_frame, text="Export Format:")
|
|
390
|
-
#export_group_frame = ttk.LabelFrame(control_frame, text = "Export Filetype Selection:")
|
|
391
|
-
export_group_frame.grid(row=1, column=2, padx=5, pady=5, sticky='nseew') # Placed in the original Checkbutton's column
|
|
392
|
-
|
|
393
|
-
ttk.Checkbutton(
|
|
394
|
-
export_group_frame,
|
|
395
|
-
#text="Export Report",
|
|
396
|
-
text = "JSON" ,
|
|
397
|
-
variable=self.do_export_report_json_var
|
|
398
|
-
).pack(side=tk.LEFT, padx=(0, 5)) # Pack Checkbutton to the left with small internal padding
|
|
399
|
-
ttk.Checkbutton(
|
|
400
|
-
export_group_frame,
|
|
401
|
-
text = "TXT" ,
|
|
402
|
-
#state=tk.DISABLED,
|
|
403
|
-
variable=self.do_export_report_txt_var,
|
|
404
|
-
).pack(side=tk.LEFT, padx=(0, 5)) # Pack Checkbutton to the left with small internal padding
|
|
405
|
-
|
|
406
|
-
# Row 3: Run Button, Export Filetype selection, License Button, and readme button
|
|
407
|
-
# 1. Run Button (Spans columns 0 and 1)
|
|
408
|
-
run_analysis_btn = ttk.Button(control_frame, text="▶ Run Analysis", command=self._run_report_gui, style='Accent.TButton')
|
|
409
|
-
run_analysis_btn.grid(row=3, column=0, columnspan=2, pady=10, sticky='ew', padx=(0, 5))
|
|
410
|
-
|
|
411
|
-
"""
|
|
412
|
-
# 2. Create a Frame to hold the two file link buttons (This frame goes into column 2)
|
|
413
|
-
info_btn_frame = ttk.Frame(control_frame)
|
|
414
|
-
info_btn_frame.grid(row=3, column=2, columnspan=1, pady=10, sticky='ew', padx=(5, 0))
|
|
415
|
-
# Ensure the info button frame expands to fill its column
|
|
416
|
-
info_btn_frame.grid_columnconfigure(0, weight=1)
|
|
417
|
-
info_btn_frame.grid_columnconfigure(1, weight=1)
|
|
418
|
-
|
|
419
|
-
# 3. Placeholder buttons inside the info button frame
|
|
420
|
-
info_1_btn = ttk.Button(info_btn_frame, text="Empty1", command=self._do_stuff_1)
|
|
421
|
-
# Use PACK or a 2-column GRID inside the info_btn_frame. GRID is cleaner here.
|
|
422
|
-
info_1_btn.grid(row=0, column=0, sticky='ew', padx=(0, 2)) # Left side of the frame
|
|
423
|
-
|
|
424
|
-
info_2_btn = ttk.Button(info_btn_frame, text="Empty2", command=self._do_stuff_2)
|
|
425
|
-
info_2_btn.grid(row=0, column=1, sticky='ew', padx=(2, 0)) # Right side of the frame
|
|
426
|
-
"""
|
|
427
|
-
|
|
428
|
-
# Force the columns to distribute space evenly
|
|
429
|
-
control_frame.grid_columnconfigure(0, weight=2)
|
|
185
|
+
# === Row 3: Action Buttons ===
|
|
186
|
+
run_analysis_btn = ttk.Button(control_frame, text="▶ Run Analysis", command=self._run_report_gui, style='Accent.TButton', width=16)
|
|
187
|
+
run_analysis_btn.grid(row=3, column=0, columnspan=2, pady=6, sticky='ew', padx=(0, 3))
|
|
188
|
+
|
|
189
|
+
clear_window_btn = ttk.Button(control_frame, text="Clear Output Window", command=self._clear_output_window, width=18)
|
|
190
|
+
clear_window_btn.grid(row=3, column=2, pady=6, sticky='ew', padx=3)
|
|
191
|
+
|
|
192
|
+
# Grid configuration
|
|
193
|
+
control_frame.grid_columnconfigure(0, weight=1)
|
|
430
194
|
control_frame.grid_columnconfigure(1, weight=1)
|
|
431
195
|
control_frame.grid_columnconfigure(2, weight=1)
|
|
432
196
|
|
|
433
197
|
# --- Output Frame (Bottom) ---
|
|
434
|
-
output_frame = ttk.Frame(self, padding=(
|
|
198
|
+
output_frame = ttk.Frame(self.root, padding=(4, 2, 4, 4))
|
|
435
199
|
output_frame.pack(fill='both', expand=True)
|
|
436
200
|
|
|
437
201
|
output_header_frame = ttk.Frame(output_frame)
|
|
438
|
-
output_header_frame.pack(fill='x', pady=(0,
|
|
439
|
-
|
|
440
|
-
# Label
|
|
441
|
-
ttk.Label(output_header_frame, text="Analysis Report Output:").pack(side=tk.LEFT, fill='x', expand=True)
|
|
202
|
+
output_header_frame.pack(fill='x', pady=(0, 1))
|
|
442
203
|
|
|
443
|
-
|
|
444
|
-
bottom_btn = ttk.Button(output_header_frame, text="▼ Bottom", command=self._scroll_to_bottom, width=8)
|
|
445
|
-
bottom_btn.pack(side=tk.RIGHT, padx=(0, 5))
|
|
204
|
+
ttk.Label(output_header_frame, text="Analysis Report Logs:").pack(side=tk.LEFT, fill='x', expand=True)
|
|
446
205
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
top_btn.pack(side=tk.RIGHT, padx=(5, 5))
|
|
206
|
+
ttk.Button(output_header_frame, text="▼ Bottom", command=self._scroll_to_bottom, width=10).pack(side=tk.RIGHT, padx=(0, 2))
|
|
207
|
+
ttk.Button(output_header_frame, text="▲ Top", command=self._scroll_to_top, width=6).pack(side=tk.RIGHT, padx=2)
|
|
450
208
|
|
|
451
|
-
#
|
|
452
|
-
self.open_report_btn = ttk.Button(output_header_frame, text="Open Report", command=self._open_report_text)
|
|
453
|
-
self.open_report_btn.pack(side=tk.RIGHT, padx=(5, 5))
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
# ----------------------------------------------------
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
# Scrollable Text Widget for output
|
|
460
|
-
# Use an internal frame for text and scrollbar to ensure correct packing
|
|
209
|
+
# Scrollable Text Area
|
|
461
210
|
text_scroll_frame = ttk.Frame(output_frame)
|
|
462
|
-
text_scroll_frame.pack(fill='both', expand=True, padx=
|
|
463
|
-
|
|
464
|
-
self.output_text = tk.Text(text_scroll_frame, wrap=tk.WORD, state=tk.DISABLED, bg='#
|
|
465
|
-
self.output_text.pack(side=tk.LEFT, fill='both', expand=True)
|
|
211
|
+
text_scroll_frame.pack(fill='both', expand=True, padx=3, pady=3)
|
|
212
|
+
|
|
213
|
+
self.output_text = tk.Text(text_scroll_frame, wrap=tk.WORD, state=tk.DISABLED, bg='#2b2b2b', fg='#ffffff', font=('Monospace', 10))
|
|
214
|
+
self.output_text.pack(side=tk.LEFT, fill='both', expand=True)
|
|
466
215
|
|
|
467
|
-
# Scrollbar (Scrollbar must be packed AFTER the text widget)
|
|
468
216
|
scrollbar = ttk.Scrollbar(text_scroll_frame, command=self.output_text.yview)
|
|
469
217
|
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
470
|
-
self.output_text['yscrollcommand'] = scrollbar.set
|
|
218
|
+
self.output_text['yscrollcommand'] = scrollbar.set
|
|
219
|
+
|
|
220
|
+
# --- Event Handlers & Business Logic ---
|
|
471
221
|
|
|
472
222
|
def _select_pdf(self):
|
|
473
223
|
if self.pdf_path.get():
|
|
474
224
|
initialdir = str(Path(self.pdf_path.get()).parent)
|
|
475
|
-
|
|
225
|
+
elif pyhabitat.is_msix():
|
|
476
226
|
initialdir = str(Path.home())
|
|
227
|
+
else:
|
|
228
|
+
initialdir = str(Path.cwd())
|
|
477
229
|
|
|
478
230
|
file_path = filedialog.askopenfilename(
|
|
479
231
|
initialdir=initialdir,
|
|
@@ -483,233 +235,264 @@ class PDFLinkCheckerApp(tk.Tk):
|
|
|
483
235
|
if file_path:
|
|
484
236
|
self.pdf_path.set(get_friendly_path(file_path))
|
|
485
237
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
238
|
+
def _copy_pdf_path(self):
|
|
239
|
+
path_to_copy = self.pdf_path.get()
|
|
240
|
+
if path_to_copy:
|
|
241
|
+
try:
|
|
242
|
+
self.root.clipboard_clear()
|
|
243
|
+
self.root.clipboard_append(path_to_copy)
|
|
244
|
+
messagebox.showinfo("Copied", "PDF Path copied to clipboard.")
|
|
245
|
+
except tk.TclError as e:
|
|
246
|
+
messagebox.showerror("Copy Error", f"Clipboard access blocked: {e}")
|
|
491
247
|
else:
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
def _toggle_json_export(self):
|
|
495
|
-
"""Checkbox toggle for json filetype report."""
|
|
496
|
-
if self.do_export_report_json_var.get():
|
|
497
|
-
pass # placeholder # no side effects
|
|
498
|
-
|
|
499
|
-
def _toggle_txt_export(self):
|
|
500
|
-
"""Checkbox toggle for TXT filetype report."""
|
|
501
|
-
if self.do_export_report_txt_var.get():
|
|
502
|
-
pass # placeholder # no side effects
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
def _toggle_pdf_library(self):
|
|
507
|
-
selected_lib = self.pdf_library_var.get().lower()
|
|
508
|
-
try:
|
|
509
|
-
self.pdf_library_var.set("pypdf" if selected_lib == "pymupdf" else "pymupdf")
|
|
510
|
-
except Exception:
|
|
511
|
-
pass
|
|
512
|
-
|
|
513
|
-
def _assess_pdf_path_str(self):
|
|
514
|
-
pdf_path_str = self.pdf_path.get().strip()
|
|
515
|
-
if not pdf_path_str:
|
|
516
|
-
pdf_path_str = get_first_pdf_in_cwd()
|
|
517
|
-
if not pdf_path_str:
|
|
518
|
-
self._display_error("Error: No PDF found in current directory.")
|
|
519
|
-
return
|
|
520
|
-
|
|
521
|
-
p = Path(pdf_path_str).expanduser().resolve()
|
|
522
|
-
|
|
523
|
-
if not p.exists():
|
|
524
|
-
self._display_error(f"Error: PDF file not found at: {p}")
|
|
525
|
-
return
|
|
526
|
-
|
|
527
|
-
# Use the resolved string version for the rest of the function
|
|
528
|
-
pdf_path_str_assessed = str(p)
|
|
529
|
-
return pdf_path_str_assessed
|
|
248
|
+
messagebox.showwarning("Copy Failed", "PDF Path field is empty.")
|
|
530
249
|
|
|
531
250
|
def _run_report_gui(self):
|
|
532
|
-
|
|
533
251
|
pdf_path_str = self._assess_pdf_path_str()
|
|
534
252
|
if not pdf_path_str:
|
|
535
253
|
return
|
|
536
254
|
|
|
537
|
-
if self.show_all_links_var.get():
|
|
538
|
-
max_links_to_pass = 0
|
|
539
|
-
else:
|
|
540
|
-
try:
|
|
541
|
-
max_links_to_pass = int(self.max_links_var.get())
|
|
542
|
-
if max_links_to_pass < 0:
|
|
543
|
-
self._display_error("Error: Max Links must be a positive number (or use 'Show All').")
|
|
544
|
-
return
|
|
545
|
-
except ValueError:
|
|
546
|
-
self._display_error("Error: Max Links must be an integer.")
|
|
547
|
-
return
|
|
548
|
-
|
|
549
|
-
export_format = None # default value, if selection is not made (if selection is not active)
|
|
550
255
|
export_format = ""
|
|
551
256
|
if self.do_export_report_json_var.get():
|
|
552
|
-
export_format
|
|
257
|
+
export_format += "JSON"
|
|
553
258
|
if self.do_export_report_txt_var.get():
|
|
554
|
-
export_format
|
|
259
|
+
export_format += "TXT"
|
|
555
260
|
|
|
556
|
-
pdf_library = self.
|
|
261
|
+
pdf_library = self.pdf_library_var.get().lower()
|
|
557
262
|
|
|
558
|
-
|
|
559
|
-
# 1. Clear previous output and enable editing
|
|
560
263
|
self.output_text.config(state=tk.NORMAL)
|
|
561
264
|
self.output_text.delete('1.0', tk.END)
|
|
562
265
|
|
|
563
|
-
# 2. Redirect standard output to the Text widget
|
|
564
266
|
original_stdout = sys.stdout
|
|
565
267
|
sys.stdout = RedirectText(self.output_text)
|
|
566
|
-
|
|
268
|
+
|
|
567
269
|
try:
|
|
568
|
-
# 3. Call the core logic function
|
|
569
|
-
#self.output_text.insert(tk.END, "--- Starting Analysis ---\n")
|
|
570
270
|
report_results = run_report_and_call_exports(
|
|
571
271
|
pdf_path=pdf_path_str,
|
|
572
|
-
max_links=max_links_to_pass,
|
|
573
272
|
export_format=export_format,
|
|
574
|
-
pdf_library
|
|
273
|
+
pdf_library=pdf_library,
|
|
575
274
|
)
|
|
576
275
|
self.current_report_text = report_results.get("text", "")
|
|
577
276
|
self.current_report_data = report_results.get("data", {})
|
|
578
277
|
|
|
579
|
-
|
|
278
|
+
self.last_json_path = report_results.get("files", {}).get("export_path_json")
|
|
279
|
+
self.last_txt_path = report_results.get("files", {}).get("export_path_txt")
|
|
580
280
|
|
|
581
281
|
except Exception as e:
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
"
|
|
585
|
-
"PyMuPDF is not installed or not working.\n"
|
|
586
|
-
"Switched to pypdf engine automatically.\n\n"
|
|
587
|
-
"To use the faster PyMuPDF engine:\n"
|
|
588
|
-
"1. Install it: pip install pymupdf\n"
|
|
589
|
-
"2. Go to Tools → Clear Cache\n"
|
|
590
|
-
"3. Run analysis again"
|
|
282
|
+
messagebox.showinfo(
|
|
283
|
+
"Engine Fallback",
|
|
284
|
+
f"Error encountered with {pdf_library}: {e}\n\nFalling back to pypdf."
|
|
591
285
|
)
|
|
592
|
-
self.
|
|
593
|
-
|
|
594
|
-
|
|
286
|
+
self.pdf_library_var.set("pypdf")
|
|
595
287
|
finally:
|
|
596
|
-
# 4. Restore standard output and disable editing
|
|
597
288
|
sys.stdout = original_stdout
|
|
598
289
|
self.output_text.config(state=tk.DISABLED)
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
290
|
+
|
|
291
|
+
def _open_export_file(self, file_type: str):
|
|
292
|
+
target_path = self.last_json_path if file_type == "json" else self.last_txt_path
|
|
293
|
+
|
|
294
|
+
if target_path and Path(target_path).exists():
|
|
295
|
+
try:
|
|
296
|
+
#pyhabitat.edit_textfile(target_path)
|
|
297
|
+
threading.Thread(target=lambda: pyhabitat.edit_textfile(target_path), daemon=True).start()
|
|
298
|
+
except Exception as e:
|
|
299
|
+
messagebox.showerror("Open Error", f"Failed to open {file_type.upper()} report: {e}")
|
|
300
|
+
else:
|
|
301
|
+
messagebox.showwarning("File Not Found", f"The {file_type.upper()} report file does not exist.\n\nPlease click 'Run Analysis' to generate one.")
|
|
302
|
+
|
|
303
|
+
def _assess_pdf_path_str(self):
|
|
304
|
+
pdf_path_str = self.pdf_path.get().strip()
|
|
305
|
+
if not pdf_path_str:
|
|
306
|
+
pdf_path_str = get_first_pdf_in_cwd()
|
|
307
|
+
if not pdf_path_str:
|
|
308
|
+
self._display_error("No PDF found in current directory.")
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
p = Path(pdf_path_str).expanduser().resolve()
|
|
312
|
+
if not p.exists():
|
|
313
|
+
self._display_error(f"PDF file not found at: {p}")
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
return str(p)
|
|
317
|
+
|
|
318
|
+
# --- Utility Methods ---
|
|
319
|
+
|
|
320
|
+
def _clear_output_window(self):
|
|
321
|
+
self.output_text.config(state=tk.NORMAL)
|
|
322
|
+
self.output_text.delete('1.0', tk.END)
|
|
323
|
+
self.output_text.config(state=tk.DISABLED)
|
|
324
|
+
|
|
325
|
+
def _copy_output_to_clipboard(self):
|
|
326
|
+
content = self.output_text.get('1.0', tk.END)
|
|
327
|
+
self.root.clipboard_clear()
|
|
328
|
+
self.root.clipboard_append(content)
|
|
329
|
+
messagebox.showinfo("Clipboard", "Output buffer copied to clipboard.")
|
|
330
|
+
|
|
331
|
+
def _scroll_to_top(self):
|
|
332
|
+
self.output_text.see('1.0')
|
|
333
|
+
|
|
334
|
+
def _scroll_to_bottom(self):
|
|
619
335
|
self.output_text.see(tk.END)
|
|
620
336
|
|
|
337
|
+
def _clear_all_caches(self):
|
|
338
|
+
clear_all_caches()
|
|
339
|
+
messagebox.showinfo("Caches Cleared", f"All caches have been cleared.\nPyMuPDF available: {pymupdf_is_available()}\nPDFium available: {pdfium_is_available()}")
|
|
340
|
+
|
|
621
341
|
def _display_error(self, message):
|
|
622
|
-
|
|
623
|
-
original_state = self.output_text.cget('state')
|
|
624
|
-
if original_state == tk.DISABLED:
|
|
625
|
-
self.output_text.config(state=tk.NORMAL)
|
|
626
|
-
|
|
627
|
-
#self.output_text.delete('1.0', tk.END)
|
|
342
|
+
self.output_text.config(state=tk.NORMAL)
|
|
628
343
|
self.output_text.insert(tk.END, f"[ERROR] {message}\n", 'error')
|
|
629
344
|
self.output_text.tag_config('error', foreground='red')
|
|
630
345
|
self.output_text.see(tk.END)
|
|
631
|
-
|
|
632
|
-
# Restore state
|
|
633
346
|
self.output_text.config(state=tk.DISABLED)
|
|
634
347
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
messagebox.showwarning("Open Failed", "No analysis data available. Please run an analysis first.")
|
|
640
|
-
return
|
|
348
|
+
# --- Modal Documentation Windows ---
|
|
349
|
+
|
|
350
|
+
def _show_license(self):
|
|
351
|
+
self._display_resource_window("LICENSE", "Software License")
|
|
641
352
|
|
|
353
|
+
def _show_readme(self):
|
|
354
|
+
self._display_resource_window("README.md", "pdflinkcheck README.md")
|
|
355
|
+
|
|
356
|
+
def _show_i_have_questions(self):
|
|
357
|
+
self._display_resource_window("I Have Questions.md", "I Have Questions.md")
|
|
358
|
+
|
|
359
|
+
def _display_resource_window(self, filename: str, title: str):
|
|
360
|
+
content = None
|
|
642
361
|
try:
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
362
|
+
content = (files("pdflinkcheck.data") / filename).read_text(encoding="utf-8")
|
|
363
|
+
except FileNotFoundError:
|
|
364
|
+
if is_in_git_repo():
|
|
365
|
+
messagebox.showinfo("Development Mode", f"Embedded {filename} not found.\nTrying to copy from project root...")
|
|
366
|
+
try:
|
|
367
|
+
from pdflinkcheck.datacopy import ensure_data_files_for_build
|
|
368
|
+
ensure_data_files_for_build()
|
|
369
|
+
content = (files("pdflinkcheck.data") / filename).read_text(encoding="utf-8")
|
|
370
|
+
except ImportError:
|
|
371
|
+
messagebox.showerror("Fallback Failed", "Cannot import datacopy module.")
|
|
372
|
+
return
|
|
373
|
+
except Exception as e:
|
|
374
|
+
messagebox.showerror("Copy Failed", f"Failed to copy {filename}: {e}")
|
|
375
|
+
return
|
|
376
|
+
else:
|
|
377
|
+
messagebox.showerror("Resource Missing", f"Embedded file '{filename}' not found.")
|
|
378
|
+
return
|
|
654
379
|
except Exception as e:
|
|
655
|
-
messagebox.showerror("
|
|
656
|
-
|
|
380
|
+
messagebox.showerror("Read Error", f"Failed to read {filename}: {e}")
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
content = sanitize_glyphs_for_tkinter(content)
|
|
384
|
+
#content = sanitize_glyphs_for_compatibility(content)
|
|
385
|
+
|
|
386
|
+
win = tk.Toplevel(self.root)
|
|
387
|
+
win.title(title)
|
|
388
|
+
win.geometry("700x500")
|
|
389
|
+
|
|
390
|
+
txt = tk.Text(win, wrap=tk.WORD, font=('Monospace', 10), padx=6, pady=6)
|
|
391
|
+
txt.insert(tk.END, content)
|
|
392
|
+
txt.config(state=tk.DISABLED)
|
|
393
|
+
|
|
394
|
+
sb = ttk.Scrollbar(win, command=txt.yview)
|
|
395
|
+
txt['yscrollcommand'] = sb.set
|
|
396
|
+
|
|
397
|
+
sb.pack(side=tk.RIGHT, fill=tk.Y)
|
|
398
|
+
txt.pack(fill='both', expand=True)
|
|
399
|
+
|
|
400
|
+
win.transient(self.root)
|
|
401
|
+
win.grab_set()
|
|
402
|
+
|
|
403
|
+
# --- Helper Functions ---
|
|
404
|
+
|
|
657
405
|
def sanitize_glyphs_for_tkinter(text: str) -> str:
|
|
406
|
+
normalized = unicodedata.normalize('NFKD', text)
|
|
407
|
+
sanitized = normalized.encode('ascii', 'ignore').decode('utf-8')
|
|
408
|
+
return sanitized.replace(' ', ' ')
|
|
409
|
+
|
|
410
|
+
def sanitize_glyphs_for_compatibility(text: str) -> str:
|
|
658
411
|
"""
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
cannot be mapped. This prevents the 'empty square' issue in Tkinter.
|
|
412
|
+
Replaces problematic glyphs with ASCII equivalents for WSL2/gedit compatibility.
|
|
413
|
+
Does not require external libraries like unidecode.
|
|
662
414
|
"""
|
|
663
|
-
#
|
|
664
|
-
|
|
415
|
+
# Define a explicit mapping for your validation glyphs
|
|
416
|
+
glyph_mapping = {
|
|
417
|
+
'✅': '[PASS]',
|
|
418
|
+
'🌐': '[WEB]',
|
|
419
|
+
'⚠️': '[WARN]',
|
|
420
|
+
'❌': '[FAIL]',
|
|
421
|
+
'ℹ️': '[INFO]'
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
# 1. Manual replacement for known report glyphs
|
|
425
|
+
for glyph, replacement in glyph_mapping.items():
|
|
426
|
+
text = text.replace(glyph, replacement)
|
|
665
427
|
|
|
666
|
-
# 2.
|
|
667
|
-
|
|
668
|
-
# that don't have an ASCII representation.
|
|
428
|
+
# 2. Normalize and strip remaining non-ASCII (NFKD decomposes characters)
|
|
429
|
+
normalized = unicodedata.normalize('NFKD', text)
|
|
669
430
|
sanitized = normalized.encode('ascii', 'ignore').decode('utf-8')
|
|
670
431
|
|
|
671
|
-
# 3. Clean up
|
|
672
|
-
|
|
673
|
-
return sanitized
|
|
432
|
+
# 3. Clean up double spaces created by the stripping
|
|
433
|
+
return sanitized.replace(' ', ' ')
|
|
674
434
|
|
|
675
|
-
def
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
435
|
+
def start_gui(time_auto_close: int = 0):
|
|
436
|
+
# 1. Initialize Root and Splash instantly
|
|
437
|
+
root = tk.Tk()
|
|
438
|
+
root.withdraw() # Hide the ugly default window for a split second
|
|
439
|
+
|
|
440
|
+
from pdflinkcheck.splash import SplashFrame
|
|
441
|
+
splash = SplashFrame(root)
|
|
442
|
+
root.update() # Force drawing the splash screen
|
|
443
|
+
|
|
444
|
+
# app = PDFLinkCheckApp(root=root)
|
|
445
|
+
# App Initialization
|
|
446
|
+
print("pdflinkcheck: Initializing PDF Link Check Engine...")
|
|
447
|
+
try:
|
|
448
|
+
app = PDFLinkCheckApp(root=root)
|
|
449
|
+
except Exception as e:
|
|
450
|
+
print(f"Startup Error: {e}")
|
|
451
|
+
root.destroy()
|
|
683
452
|
return
|
|
453
|
+
|
|
454
|
+
# === Artificial Loading Delay ===4
|
|
455
|
+
DEV_DELAY = False
|
|
456
|
+
if DEV_DELAY:
|
|
457
|
+
import time
|
|
458
|
+
for _ in range(40):
|
|
459
|
+
if not root.winfo_exists(): return
|
|
460
|
+
time.sleep(0.05)
|
|
461
|
+
root.update()
|
|
462
|
+
# ====================================
|
|
463
|
+
|
|
464
|
+
# Handover
|
|
465
|
+
if root.winfo_exists():
|
|
466
|
+
splash.teardown() # The Splash cleans itself up
|
|
467
|
+
|
|
468
|
+
# Re-center the MAIN app window before showing it
|
|
469
|
+
app_w, app_h = 700, 500
|
|
470
|
+
# Center and then reveal
|
|
471
|
+
center_window_on_primary(root, app_w, app_h)
|
|
472
|
+
|
|
473
|
+
root.deiconify()
|
|
474
|
+
# Restore window borders/decorations
|
|
475
|
+
#root.overrideredirect(False)
|
|
684
476
|
|
|
477
|
+
# Force a title update to kick the window manager
|
|
478
|
+
root.title(f"PDF Link Check v{get_version_from_pyproject()}")
|
|
685
479
|
|
|
480
|
+
root.lift()
|
|
481
|
+
root.wm_attributes("-topmost", True)
|
|
482
|
+
root.after(200, lambda: root.wm_attributes("-topmost", False))
|
|
686
483
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
tk_app.lift()
|
|
697
|
-
#tk_app.attributes('-topmost', True)
|
|
698
|
-
#tk_app.after(100, lambda: tk_app.attributes('-topmost', False))
|
|
699
|
-
tk_app.wm_attributes("-topmost", True)
|
|
700
|
-
tk_app.after(200, lambda: tk_app.wm_attributes("-topmost", False))
|
|
701
|
-
tk_app.deiconify()
|
|
702
|
-
tk_app.focus_force()
|
|
703
|
-
|
|
704
|
-
# Win32 nudge (optional but helpful)
|
|
705
|
-
if pyhabitat.on_windows():
|
|
706
|
-
hwnd = tk_app.winfo_id()
|
|
707
|
-
ctypes.windll.user32.SetForegroundWindow(hwnd)
|
|
708
|
-
|
|
709
|
-
# Ths is called in the CLI by the --auto-close flag value, for CI scripted testing purposes (like in .github/workflows/build.yml)
|
|
710
|
-
auto_close_window(tk_app, time_auto_close)
|
|
484
|
+
if pyhabitat.on_windows():
|
|
485
|
+
try:
|
|
486
|
+
hwnd = root.winfo_id()
|
|
487
|
+
ctypes.windll.user32.SetForegroundWindow(hwnd)
|
|
488
|
+
except:
|
|
489
|
+
pass
|
|
490
|
+
|
|
491
|
+
if time_auto_close > 0:
|
|
492
|
+
root.after(time_auto_close, root.destroy)
|
|
711
493
|
|
|
712
|
-
|
|
494
|
+
root.focus_force()
|
|
495
|
+
root.mainloop()
|
|
713
496
|
print("pdflinkcheck: gui closed.")
|
|
714
497
|
|
|
715
498
|
if __name__ == "__main__":
|