pdflinkcheck 1.1.7__py3-none-any.whl → 1.1.72__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/gui.py CHANGED
@@ -1,11 +1,26 @@
1
1
  # src/pdflinkcheck/gui.py
2
2
  import tkinter as tk
3
- from tkinter import filedialog, ttk
3
+ from tkinter import filedialog, ttk, messagebox # Added messagebox
4
4
  import sys
5
5
  from pathlib import Path
6
-
6
+ from typing import Optional # Added Optional
7
+ import unicodedata
8
+ from importlib.resources import files
9
+ import pyhabitat
10
+ """
11
+ try:
12
+ import sv_ttk
13
+ # Apply Sun Valley Tk theme
14
+ sv_ttk.set_theme("light")
15
+ except Exception:
16
+ # Theme not available in bundle — use default
17
+ pass
18
+ """
7
19
  # Import the core analysis function
8
- from pdflinkcheck.analyze import run_analysis
20
+ from pdflinkcheck.report import run_report
21
+ from pdflinkcheck.validate import run_validation
22
+ from pdflinkcheck.version_info import get_version_from_pyproject
23
+ from pdflinkcheck.io import get_first_pdf_in_cwd, get_friendly_path, PDFLINKCHECK_HOME
9
24
 
10
25
  class RedirectText:
11
26
  """A class to redirect sys.stdout messages to a Tkinter Text widget."""
@@ -16,85 +31,327 @@ class RedirectText:
16
31
  """Insert the incoming string into the Text widget."""
17
32
  self.text_widget.insert(tk.END, string)
18
33
  self.text_widget.see(tk.END) # Scroll to the end
19
- self.text_widget.update_idletasks() # Refresh GUI
34
+ self.text_widget.update_idletasks() # Refresh GUI to allow real timie updates << If suppress: The mainloop will handle updates efficiently without forcing them, , but info appears outdated when a new file is analyzed. Immediate feedback is better.
20
35
 
21
- def flush(self):
36
+ def flush(self, *args):
22
37
  """Required for file-like objects, but does nothing here."""
23
38
  pass
24
39
 
25
40
  class PDFLinkCheckerApp(tk.Tk):
26
41
  def __init__(self):
27
42
  super().__init__()
28
- self.title("PDF Link Checker")
43
+ self.title(f"PDF Link Check v{get_version_from_pyproject()}")
29
44
  self.geometry("800x600")
30
45
 
31
46
  # Style for the application
32
47
  style = ttk.Style(self)
33
48
  style.theme_use('clam')
34
49
 
50
+ # --- 1. Initialize Variables ---
35
51
  self.pdf_path = tk.StringVar(value="")
36
- self.check_remnants_var = tk.BooleanVar(value=True)
52
+ self.pdf_library_var = tk.StringVar(value="PyMuPDF")
53
+ #self.pdf_library_var.set("PyMuPDF")
37
54
  self.max_links_var = tk.StringVar(value="50")
38
- self.show_all_links_var = tk.BooleanVar(value=False)
55
+ self.show_all_links_var = tk.BooleanVar(value=True)
56
+ self.do_export_report_json_var = tk.BooleanVar(value=True)
57
+ self.do_export_report_txt_var = tk.BooleanVar(value=False)
58
+ self.current_report_text = None
59
+ self.current_report_data = None
60
+
61
+ self.supported_export_formats = ["JSON", "MD", "TXT"]
62
+ self.supported_export_formats = ["JSON"]
39
63
 
64
+
65
+ # --- 2. Create Widgets ---
40
66
  self._create_widgets()
67
+
68
+ # --- 3. Set Initial Dependent Widget States ---
69
+ self._toggle_max_links_entry()
70
+ self._toggle_json_export()
71
+ self._toggle_txt_export()
72
+
73
+ # In class PDFLinkCheckerApp:
74
+
75
+ def _copy_pdf_path(self):
76
+ """Copies the current PDF path from the Entry widget to the system clipboard."""
77
+ path_to_copy = self.pdf_path.get()
78
+
79
+ if path_to_copy:
80
+ try:
81
+ # Clear the clipboard
82
+ self.clipboard_clear()
83
+ # Append the path string to the clipboard
84
+ self.clipboard_append(path_to_copy)
85
+ # Notify the user (optional, but good UX)
86
+ messagebox.showinfo("Copied", "PDF Path copied to clipboard.")
87
+ except tk.TclError as e:
88
+ # Handle cases where clipboard access might be blocked
89
+ messagebox.showerror("Copy Error", f"Failed to access the system clipboard: {e}")
90
+ else:
91
+ messagebox.showwarning("Copy Failed", "The PDF Path field is empty.")
92
+
93
+ def _scroll_to_top(self):
94
+ """Scrolls the output text widget to the top."""
95
+ self.output_text.see('1.0') # '1.0' is the index for the very first character
96
+
97
+ def _scroll_to_bottom(self):
98
+ """Scrolls the output text widget to the bottom."""
99
+ self.output_text.see(tk.END) # tk.END is the index for the position just after the last character
100
+
101
+ def _show_license(self):
102
+ """
103
+ Reads the embedded LICENSE file (AGPLv3) and displays its content in a new modal window.
104
+ """
105
+ try:
106
+ # CORRECT WAY: Use the Traversable object's read_text() method.
107
+ # This handles files located inside zip archives (.pyz, pipx venvs) correctly.
108
+ license_path_traversable = files("pdflinkcheck.data") / "LICENSE"
109
+ license_content = license_path_traversable.read_text(encoding="utf-8")
110
+
111
+ except FileNotFoundError:
112
+ messagebox.showerror(
113
+ "License Error",
114
+ "LICENSE file not found within the installation package (pdflinkcheck.data/LICENSE). Check build process."
115
+ )
116
+ return
117
+ except Exception as e:
118
+ messagebox.showerror("Read Error", f"Failed to read embedded LICENSE file: {e}")
119
+ return
120
+
121
+ # --- Display in a New Toplevel Window ---
122
+ license_window = tk.Toplevel(self)
123
+ license_window.title("Software License")
124
+ license_window.geometry("600x400")
125
+
126
+ # Text widget for content
127
+ text_widget = tk.Text(license_window, wrap=tk.WORD, font=('Monospace', 10), padx=10, pady=10)
128
+ text_widget.insert(tk.END, license_content)
129
+ text_widget.config(state=tk.DISABLED)
130
+
131
+ # Scrollbar
132
+ scrollbar = ttk.Scrollbar(license_window, command=text_widget.yview)
133
+ text_widget['yscrollcommand'] = scrollbar.set
134
+
135
+ # Layout
136
+ scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
137
+ text_widget.pack(fill='both', expand=True)
138
+
139
+ # Make the window modal (optional, but good practice for notices)
140
+ license_window.transient(self)
141
+ license_window.grab_set()
142
+ self.wait_window(license_window)
143
+
144
+ def _show_readme(self):
145
+ """
146
+ Reads the embedded README.md file and displays its content in a new modal window.
147
+ """
148
+ try:
149
+ # CORRECT WAY: Use the Traversable object's read_text() method.
150
+ # This handles files located inside zip archives (.pyz, pipx venvs) correctly.
151
+ readme_path_traversable = files("pdflinkcheck.data") / "README.md"
152
+ readme_content = readme_path_traversable.read_text(encoding="utf-8")
153
+ readme_content = sanitize_glyphs_for_tkinter(readme_content)
154
+
155
+ except FileNotFoundError:
156
+ messagebox.showerror(
157
+ "Readme Error",
158
+ "README.md file not found within the installation package (pdflinkcheck.data/README.md). Check build process."
159
+ )
160
+ return
161
+ except Exception as e:
162
+ messagebox.showerror("Read Error", f"Failed to read embedded README.md file: {e}")
163
+ return
164
+
165
+ # --- Display in a New Toplevel Window ---
166
+ readme_window = tk.Toplevel(self)
167
+ readme_window.title("pdflinkcheck README.md")
168
+ readme_window.geometry("600x400")
169
+
170
+ # Text widget for content
171
+ text_widget = tk.Text(readme_window, wrap=tk.WORD, font=('Monospace', 10), padx=10, pady=10)
172
+ text_widget.insert(tk.END, readme_content)
173
+ text_widget.config(state=tk.DISABLED)
174
+
175
+ # Scrollbar
176
+ scrollbar = ttk.Scrollbar(readme_window, command=text_widget.yview)
177
+ text_widget['yscrollcommand'] = scrollbar.set
178
+
179
+ # Layout
180
+ scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
181
+ text_widget.pack(fill='both', expand=True)
182
+
183
+ # Make the window modal (optional, but good practice for notices)
184
+ readme_window.transient(self)
185
+ readme_window.grab_set()
186
+ self.wait_window(readme_window)
41
187
 
42
188
  def _create_widgets(self):
43
189
  # --- Control Frame (Top) ---
44
190
  control_frame = ttk.Frame(self, padding="10")
45
191
  control_frame.pack(fill='x')
46
192
 
47
- # File Selection
48
- ttk.Label(control_frame, text="PDF Path:").grid(row=0, column=0, padx=5, pady=5, sticky='w')
49
- ttk.Entry(control_frame, textvariable=self.pdf_path, width=60).grid(row=0, column=1, padx=5, pady=5, sticky='ew')
50
- ttk.Button(control_frame, text="Browse...", command=self._select_pdf).grid(row=0, column=2, padx=5, pady=5)
193
+ # Row 0: File Selection
51
194
 
52
- # Options
53
- ttk.Checkbutton(
54
- control_frame,
55
- text="Check for Remnants (URLs/Emails)",
56
- variable=self.check_remnants_var
57
- ).grid(row=1, column=0, padx=5, pady=5, sticky='w')
195
+ # === File Selection Frame (Row 0) ===
196
+ file_selection_frame = ttk.Frame(control_frame)
197
+ file_selection_frame.grid(row=0, column=0, columnspan=3, padx=0, pady=5, sticky='ew')
198
+
199
+ # Elements are now packed/gridded within file_selection_frame
200
+
201
+ # Label
202
+ ttk.Label(file_selection_frame, text="PDF Path:").pack(side=tk.LEFT, padx=(0, 5))
203
+
204
+ # Entry (Path Display)
205
+ ttk.Entry(file_selection_frame, textvariable=self.pdf_path, width=50).pack(side=tk.LEFT, fill='x', expand=True, padx=5)
206
+ # The Entry field (column 1) must expand horizontally within its frame
207
+ # Since we are using PACK for this frame, we use fill='x', expand=True on the Entry.
208
+
209
+ # Browse Button
210
+ ttk.Button(file_selection_frame, text="Browse...", command=self._select_pdf).pack(side=tk.LEFT, padx=(5, 5))
58
211
 
212
+ # Copy Button
213
+ # NOTE: Removed leading spaces from " Copy Path"
214
+ ttk.Button(file_selection_frame, text="Copy Path", command=self._copy_pdf_path).pack(side=tk.LEFT, padx=(0, 0))
215
+
216
+ # === END: File Selection Frame ===
217
+
218
+ # --- Report brevity options ----
219
+ report_brevity_frame = ttk.LabelFrame(control_frame, text="Report Brevity Options:")
220
+ #report_brevity_frame.grid(row=1, column=0, columnspan=2, padx=5, pady=1, sticky='nsew')
221
+ report_brevity_frame.grid(row=1, column=0, padx=5, pady=5, sticky='nsew')
222
+ #
59
223
  ttk.Checkbutton(
60
- control_frame,
61
- text="Show All Links (Override Max)",
224
+ report_brevity_frame,
225
+ text="Show All Links.",
62
226
  variable=self.show_all_links_var,
63
- # Optional: Disable max_links entry when this is checked
64
227
  command=self._toggle_max_links_entry
65
- ).grid(row=2, column=0, padx=5, pady=5, sticky='w')
228
+ ).pack(side='left', padx=5, pady=1)
229
+
230
+ ttk.Label(report_brevity_frame, text="Max Links to Display:").pack(side='left', padx=5, pady=1)
231
+ self.max_links_entry = ttk.Entry(report_brevity_frame, textvariable=self.max_links_var, width=4)
232
+ self.max_links_entry.pack(side='left', padx=5, pady=5)
66
233
 
67
- ttk.Label(control_frame, text="Max Links to Display:").grid(row=1, column=1, padx=5, pady=5, sticky='e')
68
- self.max_links_entry = ttk.Entry(control_frame, textvariable=self.max_links_var, width=10)
69
- self.max_links_entry.grid(row=1, column=2, padx=5, pady=5, sticky='w')
234
+ # --- PDF Library Selection ---
235
+ # Create a labeled group for the PDF options
236
+ pdf_library_frame = ttk.LabelFrame(control_frame, text="Select PDF Library:")
237
+ pdf_library_frame.grid(row=1, column=1, padx=5, pady=5, sticky='nsew')
238
+
239
+ # Radio options inside the frame
240
+ ttk.Radiobutton(
241
+ pdf_library_frame,
242
+ text="PyMuPDF",
243
+ variable=self.pdf_library_var,
244
+ value="PyMuPDF",
245
+
246
+ ).pack(side='left', padx=5, pady=1)
247
+
248
+ ttk.Radiobutton(
249
+ pdf_library_frame,
250
+ text="pypdf",
251
+ variable=self.pdf_library_var,
252
+ value="pypdf",
253
+ ).pack(side='left', padx=5, pady=1)
254
+
255
+ export_group_frame = ttk.LabelFrame(control_frame, text="Export Format:")
256
+ #export_group_frame = ttk.LabelFrame(control_frame, text = "Export Filetype Selection:")
257
+ export_group_frame.grid(row=1, column=2, padx=5, pady=5, sticky='nseew') # Placed in the original Checkbutton's column
70
258
 
71
- # Run Button
72
- ttk.Button(control_frame, text="▶ Run Analysis", command=self._run_analysis_gui, style='Accent.TButton').grid(row=2, column=0, columnspan=3, pady=10)
259
+ ttk.Checkbutton(
260
+ export_group_frame,
261
+ #text="Export Report",
262
+ text = "JSON" ,
263
+ variable=self.do_export_report_json_var
264
+ ).pack(side=tk.LEFT, padx=(0, 5)) # Pack Checkbutton to the left with small internal padding
265
+ ttk.Checkbutton(
266
+ export_group_frame,
267
+ text = "TXT" ,
268
+ #state=tk.DISABLED,
269
+ variable=self.do_export_report_txt_var,
270
+ ).pack(side=tk.LEFT, padx=(0, 5)) # Pack Checkbutton to the left with small internal padding
73
271
 
272
+ # Row 3: Run Button, Export Filetype selection, License Button, and readme button
273
+ # 1. Run Button (Spans columns 0 and 1)
274
+ run_analysis_btn = ttk.Button(control_frame, text="▶ Run Analysis", command=self._run_report_gui, style='Accent.TButton')
275
+ run_analysis_btn.grid(row=3, column=0, columnspan=2, pady=10, sticky='ew', padx=(0, 5))
276
+
277
+ run_validation_btn = ttk.Button(control_frame, text="▶ Run Validation", command=self._run_validation_gui, style='Accent.TButton')
278
+ run_validation_btn.grid(row=4, column=0, columnspan=2, pady=10, sticky='ew', padx=(0, 5))
279
+ # Ensure the run button frame expands to fill its column
280
+ #run_analysis_btn.grid_columnconfigure(0, weight=1)
281
+
282
+ # 2. Create a Frame to hold the two file link buttons (This frame goes into column 2)
283
+ info_btn_frame = ttk.Frame(control_frame)
284
+ info_btn_frame.grid(row=3, column=2, columnspan=1, pady=10, sticky='ew', padx=(5, 0))
285
+ # Ensure the info button frame expands to fill its column
286
+ info_btn_frame.grid_columnconfigure(0, weight=1)
287
+ info_btn_frame.grid_columnconfigure(1, weight=1)
288
+
289
+ # 3. Place License and Readme buttons inside the new frame
290
+ license_btn = ttk.Button(info_btn_frame, text="License", command=self._show_license)
291
+ # Use PACK or a 2-column GRID inside the info_btn_frame. GRID is cleaner here.
292
+ license_btn.grid(row=0, column=0, sticky='ew', padx=(0, 2)) # Left side of the frame
293
+
294
+ readme_btn = ttk.Button(info_btn_frame, text="Readme", command=self._show_readme)
295
+ readme_btn.grid(row=0, column=1, sticky='ew', padx=(2, 0)) # Right side of the frame
296
+
297
+ # Force the columns to distribute space evenly
298
+ control_frame.grid_columnconfigure(0, weight=2)
74
299
  control_frame.grid_columnconfigure(1, weight=1)
300
+ control_frame.grid_columnconfigure(2, weight=1)
75
301
 
76
302
  # --- Output Frame (Bottom) ---
77
- output_frame = ttk.Frame(self, padding="10")
303
+ output_frame = ttk.Frame(self, padding=(10, 2, 10, 10)) # Left, Top, Right, Bottom
78
304
  output_frame.pack(fill='both', expand=True)
79
305
 
80
- ttk.Label(output_frame, text="Analysis Report Output:").pack(fill='x')
306
+ output_header_frame = ttk.Frame(output_frame)
307
+ output_header_frame.pack(fill='x', pady=(0, 5))
308
+
309
+ # Label
310
+ ttk.Label(output_header_frame, text="Analysis Report Output:").pack(side=tk.LEFT, fill='x', expand=True)
311
+
312
+ # Scroll to Bottom Button # put this first so that it on the right when the Top button is added on the left.
313
+ bottom_btn = ttk.Button(output_header_frame, text="▼ Bottom", command=self._scroll_to_bottom, width=8)
314
+ bottom_btn.pack(side=tk.RIGHT, padx=(0, 5))
315
+
316
+ # Scroll to Top Button
317
+ top_btn = ttk.Button(output_header_frame, text="▲ Top", command=self._scroll_to_top, width=6)
318
+ top_btn.pack(side=tk.RIGHT, padx=(5, 5))
319
+
320
+ # Open Report Button
321
+ self.open_report_btn = ttk.Button(output_header_frame, text="Open Report", command=self._open_report_text)
322
+ self.open_report_btn.pack(side=tk.RIGHT, padx=(5, 5))
323
+
324
+
325
+ # ----------------------------------------------------
326
+
81
327
 
82
328
  # Scrollable Text Widget for output
83
- self.output_text = tk.Text(output_frame, wrap=tk.WORD, state=tk.DISABLED, bg='#333333', fg='white', font=('Monospace', 10))
84
- self.output_text.pack(fill='both', expand=True, padx=5, pady=5)
329
+ # Use an internal frame for text and scrollbar to ensure correct packing
330
+ text_scroll_frame = ttk.Frame(output_frame)
331
+ text_scroll_frame.pack(fill='both', expand=True, padx=5, pady=5)
85
332
 
86
- # Scrollbar
87
- scrollbar = ttk.Scrollbar(output_frame, command=self.output_text.yview)
88
- self.output_text['yscrollcommand'] = scrollbar.set
333
+ self.output_text = tk.Text(text_scroll_frame, wrap=tk.WORD, state=tk.DISABLED, bg='#333333', fg='white', font=('Monospace', 10))
334
+ self.output_text.pack(side=tk.LEFT, fill='both', expand=True) # Text fills and expands
335
+
336
+ # Scrollbar (Scrollbar must be packed AFTER the text widget)
337
+ scrollbar = ttk.Scrollbar(text_scroll_frame, command=self.output_text.yview)
89
338
  scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
339
+ self.output_text['yscrollcommand'] = scrollbar.set # Link text widget back to scrollbar
90
340
 
91
341
  def _select_pdf(self):
342
+ if self.pdf_path.get():
343
+ initialdir = str(Path(self.pdf_path.get()).parent)
344
+ else:
345
+ initialdir = str(Path.cwd())
346
+
92
347
  file_path = filedialog.askopenfilename(
348
+ initialdir=initialdir,
93
349
  defaultextension=".pdf",
94
350
  filetypes=[("PDF files", "*.pdf"), ("All files", "*.*")]
95
351
  )
96
352
  if file_path:
97
- self.pdf_path.set(file_path)
353
+ self.pdf_path.set(get_friendly_path(file_path))
354
+
98
355
 
99
356
  def _toggle_max_links_entry(self):
100
357
  """Disables/enables the max_links entry based on show_all_links_var."""
@@ -102,26 +359,63 @@ class PDFLinkCheckerApp(tk.Tk):
102
359
  self.max_links_entry.config(state=tk.DISABLED)
103
360
  else:
104
361
  self.max_links_entry.config(state=tk.NORMAL)
362
+
363
+ def _toggle_json_export(self):
364
+ """Checkbox toggle for json filetype report."""
365
+ if self.do_export_report_json_var.get():
366
+ pass # placeholder # no side effects
105
367
 
106
- def _run_analysis_gui(self):
107
- pdf_path_str = self.pdf_path.get()
108
- if not Path(pdf_path_str).exists():
109
- self._display_error("Error: PDF file not found or path is invalid.")
368
+ def _toggle_txt_export(self):
369
+ """Checkbox toggle for TXT filetype report."""
370
+ if self.do_export_report_txt_var.get():
371
+ pass # placeholder # no side effects
372
+
373
+ def _assess_pdf_path_str(self):
374
+ pdf_path_str = self.pdf_path.get().strip()
375
+ if not pdf_path_str:
376
+ pdf_path_str = get_first_pdf_in_cwd()
377
+ if not pdf_path_str:
378
+ self._display_error("Error: No PDF found in current directory.")
379
+ return
380
+
381
+ p = Path(pdf_path_str).expanduser().resolve()
382
+
383
+ if not p.exists():
384
+ self._display_error(f"Error: PDF file not found at: {p}")
110
385
  return
386
+
387
+ # Use the resolved string version for the rest of the function
388
+ pdf_path_str_assessed = str(p)
389
+ return pdf_path_str_assessed
390
+
391
+ def _run_report_gui(self):
111
392
 
393
+ pdf_path_str = self._assess_pdf_path_str()
394
+ if not pdf_path_str:
395
+ return
396
+
112
397
  if self.show_all_links_var.get():
113
- # Pass 0 to the backend, which analyze.py interprets as "Show All"
114
398
  max_links_to_pass = 0
115
399
  else:
116
400
  try:
117
401
  max_links_to_pass = int(self.max_links_var.get())
118
- if max_links_to_pass <= 0:
402
+ if max_links_to_pass < 1:
119
403
  self._display_error("Error: Max Links must be a positive number (or use 'Show All').")
120
404
  return
121
405
  except ValueError:
122
406
  self._display_error("Error: Max Links must be an integer.")
123
407
  return
124
408
 
409
+ export_format = None # default value, if selection is not made (if selection is not active)
410
+ export_format = ""
411
+ if self.do_export_report_json_var.get():
412
+ export_format = export_format + "JSON"
413
+ if self.do_export_report_txt_var.get():
414
+ export_format = export_format + "TXT"
415
+
416
+ pdf_library = self._discern_pdf_library()
417
+
418
+
125
419
  # 1. Clear previous output and enable editing
126
420
  self.output_text.config(state=tk.NORMAL)
127
421
  self.output_text.delete('1.0', tk.END)
@@ -132,34 +426,165 @@ class PDFLinkCheckerApp(tk.Tk):
132
426
 
133
427
  try:
134
428
  # 3. Call the core logic function
135
- self.output_text.insert(tk.END, "--- Starting Analysis ---\n")
136
- run_analysis(
429
+ #self.output_text.insert(tk.END, "--- Starting Analysis ---\n")
430
+ report_results = run_report(
137
431
  pdf_path=pdf_path_str,
138
- check_remnants=self.check_remnants_var.get(),
139
- max_links=max_links_to_pass
432
+ max_links=max_links_to_pass,
433
+ export_format=export_format,
434
+ pdf_library = pdf_library,
140
435
  )
141
- self.output_text.insert(tk.END, "\n--- Analysis Complete ---\n")
436
+ self.current_report_text = report_results.get("text", "")
437
+ self.current_report_data = report_results.get("data", {})
438
+
439
+ #self.output_text.insert(tk.END, "\n--- Analysis Complete ---\n")
142
440
 
143
441
  except Exception as e:
442
+ # Inform the user in the GUI with a clean message
144
443
  self._display_error(f"An unexpected error occurred during analysis: {e}")
145
444
 
146
445
  finally:
147
446
  # 4. Restore standard output and disable editing
148
447
  sys.stdout = original_stdout
149
448
  self.output_text.config(state=tk.DISABLED)
449
+
450
+ def _run_validation_gui(self):
451
+
452
+ pdf_path_str = self._assess_pdf_path_str()
453
+ if not pdf_path_str:
454
+ return
150
455
 
151
- def _display_error(self, message):
456
+ pdf_library = self._discern_pdf_library()
457
+
458
+ # 1. Clear previous output and enable editing
152
459
  self.output_text.config(state=tk.NORMAL)
153
460
  self.output_text.delete('1.0', tk.END)
461
+
462
+ # 2. Redirect standard output to the Text widget
463
+ original_stdout = sys.stdout
464
+ sys.stdout = RedirectText(self.output_text)
465
+
466
+ if not self.current_report_data:
467
+ self._run_report_gui()
468
+ report_results = self.current_report_data
469
+
470
+ try:
471
+ # 3. Call the core logic function
472
+ #self.output_text.insert(tk.END, "--- Starting Analysis ---\n")
473
+ validation_results = run_validation(
474
+ report_results=report_results,
475
+ pdf_path=pdf_path_str,
476
+ pdf_library=pdf_library,
477
+ export_json=True,
478
+ print_bool=True
479
+ )
480
+ self.current_report_text = report_results.get("text", "")
481
+ self.current_report_data = report_results.get("data", {})
482
+
483
+ #self.output_text.insert(tk.END, "\n--- Analysis Complete ---\n")
484
+
485
+ except Exception as e:
486
+ # Inform the user in the GUI with a clean message
487
+ self._display_error(f"An unexpected error occurred during analysis: {e}")
488
+
489
+ finally:
490
+ # 4. Restore standard output and disable editing
491
+ sys.stdout = original_stdout
492
+ self.output_text.config(state=tk.DISABLED)
493
+
494
+
495
+ def _discern_pdf_library(self):
496
+ selected_lib = self.pdf_library_var.get().lower()
497
+
498
+ if selected_lib == "pymupdf":
499
+ print("Using high-speed PyMuPDF engine.")
500
+ elif selected_lib == "pypdf":
501
+ print("Using pure-python pypdf engine.")
502
+ return selected_lib
503
+
504
+ def _display_error(self, message):
505
+ # Ensure output is in normal state to write
506
+ original_state = self.output_text.cget('state')
507
+ if original_state == tk.DISABLED:
508
+ self.output_text.config(state=tk.NORMAL)
509
+
510
+ #self.output_text.delete('1.0', tk.END)
154
511
  self.output_text.insert(tk.END, f"[ERROR] {message}\n", 'error')
155
512
  self.output_text.tag_config('error', foreground='red')
513
+ self.output_text.see(tk.END)
514
+
515
+ # Restore state
156
516
  self.output_text.config(state=tk.DISABLED)
157
517
 
518
+ def _open_report_text(self):
519
+ """Opens the LATEST analysis text in an editor, regardless of export settings."""
520
+ # 1. Check our internal buffer, not the window or the disk
521
+ if not self.current_report_text:
522
+ messagebox.showwarning("Open Failed", "No analysis data available. Please run an analysis first.")
523
+ return
524
+
525
+ try:
526
+ # 2. Always create a 'viewing' file in a temp directory or .tmp folder
527
+ # This prevents clobbering an actual user-saved report.
528
+ pdf_name = Path(self.pdf_path.get()).stem if self.pdf_path.get() else "report"
529
+ view_path = PDFLINKCHECK_HOME / f"LAST_REPORT_{pdf_name}.txt"
530
+
531
+ # 3. Write our buffer to this 'View' file
532
+ view_path.write_text(self.current_report_text, encoding="utf-8")
533
+
534
+ # 4. Open with pyhabitat
535
+ pyhabitat.edit_textfile(view_path)
536
+
537
+ except Exception as e:
538
+ messagebox.showerror("View Error", f"Could not launch editor: {e}")
539
+
540
+ """
541
+ def toggle_theme():
542
+ try:
543
+ current = sv_ttk.get_theme()
544
+ sv_ttk.set_theme("dark" if current == "light" else "light")
545
+ except Exception:
546
+ pass
547
+ """
548
+ def sanitize_glyphs_for_tkinter(text: str) -> str:
549
+ """
550
+ Converts complex Unicode characters (like emojis and symbols)
551
+ into their closest ASCII representation, ignoring those that
552
+ cannot be mapped. This prevents the 'empty square' issue in Tkinter.
553
+ """
554
+ # 1. Normalize the text (NFKD converts composite characters to their base parts)
555
+ normalized = unicodedata.normalize('NFKD', text)
556
+
557
+ # 2. Encode to ASCII and decode back.
558
+ # The 'ignore' flag is crucial: it removes any characters
559
+ # that don't have an ASCII representation.
560
+ sanitized = normalized.encode('ascii', 'ignore').decode('utf-8')
561
+
562
+ # 3. Clean up any resulting double spaces or artifacts
563
+ sanitized = sanitized.replace(' ', ' ')
564
+ return sanitized
565
+
566
+ def auto_close_window(root, delay_ms:int = 0):
567
+ """
568
+ Schedules the Tkinter window to be destroyed after a specified delay.
569
+ """
570
+ if delay_ms > 0:
571
+ print(f"Window is set to automatically close in {delay_ms/1000} seconds.")
572
+ root.after(delay_ms, root.destroy)
573
+ else:
574
+ return
575
+
576
+
577
+ def start_gui(time_auto_close:int=0):
578
+ """
579
+ Entry point function to launch the application.
580
+ """
581
+ print("pdflinkcheck: start_gui ...")
582
+ tk_app = PDFLinkCheckerApp()
583
+
584
+ auto_close_window(tk_app, time_auto_close)
158
585
 
159
- def start_gui():
160
- """Entry point function to launch the application."""
161
- app = PDFLinkCheckerApp()
162
- app.mainloop()
586
+ tk_app.mainloop()
587
+ print("pdflinkcheck: gui closed.")
163
588
 
164
589
  if __name__ == "__main__":
165
- start_gui()
590
+ start_gui()