pdfreading-app 0.1.0__tar.gz
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.
- pdfreading_app-0.1.0/PKG-INFO +8 -0
- pdfreading_app-0.1.0/README.md +1 -0
- pdfreading_app-0.1.0/pyproject.toml +20 -0
- pdfreading_app-0.1.0/src/pdfreading/.gitignore +0 -0
- pdfreading_app-0.1.0/src/pdfreading/__init__.py +0 -0
- pdfreading_app-0.1.0/src/pdfreading/assets/__init__.py +0 -0
- pdfreading_app-0.1.0/src/pdfreading/assets/button_icon/add.png +0 -0
- pdfreading_app-0.1.0/src/pdfreading/assets/button_icon/folder.png +0 -0
- pdfreading_app-0.1.0/src/pdfreading/assets/button_icon/open.png +0 -0
- pdfreading_app-0.1.0/src/pdfreading/assets/button_icon/remove.png +0 -0
- pdfreading_app-0.1.0/src/pdfreading/assets/button_icon/url.png +0 -0
- pdfreading_app-0.1.0/src/pdfreading/assets/button_icon/zoom_in.png +0 -0
- pdfreading_app-0.1.0/src/pdfreading/assets/button_icon/zoom_out.png +0 -0
- pdfreading_app-0.1.0/src/pdfreading/assets/text.txt +0 -0
- pdfreading_app-0.1.0/src/pdfreading/assets/title_icon/book1.ico +0 -0
- pdfreading_app-0.1.0/src/pdfreading/assets/title_icon/book2.ico +0 -0
- pdfreading_app-0.1.0/src/pdfreading/assets/title_icon/book2.png +0 -0
- pdfreading_app-0.1.0/src/pdfreading/assets/title_icon/book3.ico +0 -0
- pdfreading_app-0.1.0/src/pdfreading/main.py +105 -0
- pdfreading_app-0.1.0/src/pdfreading/openDialog/AddFile.py +14 -0
- pdfreading_app-0.1.0/src/pdfreading/openDialog/Addurl.py +149 -0
- pdfreading_app-0.1.0/src/pdfreading/openDialog/__init__.py +0 -0
- pdfreading_app-0.1.0/src/pdfreading/utils/Data.py +31 -0
- pdfreading_app-0.1.0/src/pdfreading/utils/UI.py +222 -0
- pdfreading_app-0.1.0/src/pdfreading/utils/__init__.py +0 -0
- pdfreading_app-0.1.0/src/pdfreading/utils/functionality.py +539 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Book-Reading-GUI-app
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pdfreading-app"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A PDF reader desktop app"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pillow",
|
|
12
|
+
"pymupdf",
|
|
13
|
+
"customtkinter"
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
pdfreading-app = "pdfreading.main:main"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["src/pdfreading"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import json
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import customtkinter as ctk
|
|
7
|
+
from PIL import Image, ImageTk
|
|
8
|
+
from .utils import UI, functionality, Data
|
|
9
|
+
from .utils.functionality import _register_file
|
|
10
|
+
|
|
11
|
+
# store the library folder in a variable so it can be used across different os
|
|
12
|
+
LIBARY_DIR = Path.home() / "ReadDaBookLibrary"
|
|
13
|
+
# create a dir on the first run and does nothing
|
|
14
|
+
LIBARY_DIR.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
# store the progress file in a variable so it can be used across different os
|
|
16
|
+
PROGRESS_FILE = LIBARY_DIR / "progress.json"
|
|
17
|
+
|
|
18
|
+
# the app icon or logo
|
|
19
|
+
def set_app_icon(app):
|
|
20
|
+
try:
|
|
21
|
+
# windows OS
|
|
22
|
+
if sys.platform == "win32":
|
|
23
|
+
ico_path = LIBARY_DIR / "book2.ico"
|
|
24
|
+
if not ico_path.exists():
|
|
25
|
+
src = files("pdfreader.assets.title_icon").joinpath("book2.png")
|
|
26
|
+
img = Image.open(src)
|
|
27
|
+
img.save(ico_path, format="ICO", sizes=[(16, 16), (32, 32), (64, 64)])
|
|
28
|
+
app.iconbitmap(str(ico_path))
|
|
29
|
+
# macOS
|
|
30
|
+
elif sys.platform == "darwin":
|
|
31
|
+
src = files("pdfreader.assets.title_icon").joinpath("book2.png")
|
|
32
|
+
img = Image.open(src).resize((64, 64))
|
|
33
|
+
photo = ImageTk.PhotoImage(img)
|
|
34
|
+
app.iconphoto(True, photo)
|
|
35
|
+
app._icon_photo = photo
|
|
36
|
+
else:
|
|
37
|
+
# linux and other OSes
|
|
38
|
+
src = files("pdfreader.assets.title_icon").joinpath("book2.png")
|
|
39
|
+
img = Image.open(src).resize((32, 32))
|
|
40
|
+
photo = ImageTk.PhotoImage(img)
|
|
41
|
+
app.iconphoto(True, photo)
|
|
42
|
+
app._icon_photo = photo
|
|
43
|
+
except Exception as e:
|
|
44
|
+
print(f"Could not set icon: {e}")
|
|
45
|
+
|
|
46
|
+
# load the user's reading progress
|
|
47
|
+
def load_progress():
|
|
48
|
+
if PROGRESS_FILE.exists():
|
|
49
|
+
try:
|
|
50
|
+
with open(PROGRESS_FILE, "r") as f:
|
|
51
|
+
# load the last read pages from the progress file.
|
|
52
|
+
Data.last_read_pages.update(json.load(f))
|
|
53
|
+
except Exception:
|
|
54
|
+
# if there's an error loading the progress (e.g. file is corrupted), just ignore it and start with an empty progress.
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
# save the user's reading progress to a file so it can be loaded on the next startup
|
|
58
|
+
def save_progress():
|
|
59
|
+
# if there's a currently selected file and an open document, save the current page to the last_read_pages dict
|
|
60
|
+
if Data.selected_file and Data.doc:
|
|
61
|
+
Data.last_read_pages[Data.selected_file] = functionality._current_page
|
|
62
|
+
try:
|
|
63
|
+
# open the progress file for writing and save the last_read_pages dict as json. this way we can restore the user's last read page for each book on the next startup.
|
|
64
|
+
with open(PROGRESS_FILE, "w") as f:
|
|
65
|
+
json.dump(Data.last_read_pages, f)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
print(f"Couldn't Save Progress: {e}")
|
|
68
|
+
|
|
69
|
+
def main():
|
|
70
|
+
# give functionality.py the resolved path so _copy_to_library know where to save files
|
|
71
|
+
functionality.LIBARY_DIR = LIBARY_DIR
|
|
72
|
+
# the app set up color
|
|
73
|
+
ctk.set_appearance_mode("System")
|
|
74
|
+
ctk.set_default_color_theme("blue")
|
|
75
|
+
|
|
76
|
+
# create a window to open
|
|
77
|
+
app = ctk.CTk()
|
|
78
|
+
set_app_icon(app)
|
|
79
|
+
# the app title
|
|
80
|
+
app.title("Read da book")
|
|
81
|
+
# the size of the app when not open fullscreen
|
|
82
|
+
app.geometry("1200x600")
|
|
83
|
+
|
|
84
|
+
# when the user closes the app, we want to save their progress before the app actually closes. this function is called by the WM_DELETE_WINDOW protocol handler below.
|
|
85
|
+
def on_close():
|
|
86
|
+
save_progress()
|
|
87
|
+
app.destroy()
|
|
88
|
+
|
|
89
|
+
# build the UI and attach it to the app window
|
|
90
|
+
UI.build(app)
|
|
91
|
+
load_progress()
|
|
92
|
+
# set the on_close function to be called when the user tries to close the window, so we can save their progress first
|
|
93
|
+
app.protocol("WM_DELETE_WINDOW", on_close)
|
|
94
|
+
|
|
95
|
+
'''restore previously added files from the library folder on startup'''
|
|
96
|
+
# lists every file in the library folder. sorted() puts them in alphabetical order so the list always appears in the same order regardless of filesystem ordering.
|
|
97
|
+
for fname in sorted(os.listdir(LIBARY_DIR)):
|
|
98
|
+
if fname.lower().endswith(".pdf"):
|
|
99
|
+
fpath = os.path.join(LIBARY_DIR, fname)
|
|
100
|
+
_register_file(fpath, fname)
|
|
101
|
+
app.mainloop()
|
|
102
|
+
|
|
103
|
+
# open the app
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
main()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from tkinter import filedialog as fida
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
# open file dialog and ask the user to select a pdf file
|
|
5
|
+
def add_pdf():
|
|
6
|
+
filename = fida.askopenfilename(
|
|
7
|
+
initialdir="/",
|
|
8
|
+
filetypes=[("PDF file", "*.pdf")]
|
|
9
|
+
)
|
|
10
|
+
# if a file is selected, return the file path and name, otherwise return None
|
|
11
|
+
if filename:
|
|
12
|
+
return filename, os.path.basename(filename)
|
|
13
|
+
|
|
14
|
+
return None, None
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import urllib.request
|
|
2
|
+
import os
|
|
3
|
+
import threading
|
|
4
|
+
import customtkinter as ctk
|
|
5
|
+
from ..utils import functionality
|
|
6
|
+
|
|
7
|
+
# clean the URL nicely remove the query parameters and get the filename, if the filename doesn't end with .pdf we just name it download.pdf,
|
|
8
|
+
# and we also remove any characters that are not alphanumeric or ._- to avoid issues with the filesystem
|
|
9
|
+
def _sanitize_filename(url: str) -> str:
|
|
10
|
+
name = url.rstrip("/").split("/")[-1].split("?")[0]
|
|
11
|
+
if not name.lower().endswith(".pdf"):
|
|
12
|
+
name = "download.pdf"
|
|
13
|
+
|
|
14
|
+
new_name = ""
|
|
15
|
+
for c in name:
|
|
16
|
+
if c.isalnum() or c in "._- ":
|
|
17
|
+
new_name += c
|
|
18
|
+
name = new_name
|
|
19
|
+
return name or "download.pdf"
|
|
20
|
+
|
|
21
|
+
# Opens a modal dialog asking the user for a PDF URL.
|
|
22
|
+
def open_url_dialog(app, on_success):
|
|
23
|
+
dialog = ctk.CTkToplevel(app)
|
|
24
|
+
dialog.title("Load PDF from URL")
|
|
25
|
+
dialog.geometry("480x220")
|
|
26
|
+
dialog.resizable(False, False)
|
|
27
|
+
dialog.transient(app)
|
|
28
|
+
dialog.grab_set()
|
|
29
|
+
|
|
30
|
+
my_font = ctk.CTkFont(family="Arial", size=13, weight="bold")
|
|
31
|
+
small_font = ctk.CTkFont(family="Arial", size=11)
|
|
32
|
+
# the title and entry box for the URL
|
|
33
|
+
ctk.CTkLabel(dialog, text="PDF URL:", font=my_font).pack(
|
|
34
|
+
anchor="w", padx=20, pady=(20, 2)
|
|
35
|
+
)
|
|
36
|
+
url_entry = ctk.CTkEntry(
|
|
37
|
+
dialog,
|
|
38
|
+
width=440,
|
|
39
|
+
font=small_font,
|
|
40
|
+
placeholder_text="https://example.com/file.pdf"
|
|
41
|
+
)
|
|
42
|
+
url_entry.pack(padx=20)
|
|
43
|
+
|
|
44
|
+
status_label = ctk.CTkLabel(dialog, text="", font=small_font, text_color="gray70")
|
|
45
|
+
status_label.pack(pady=(8, 0))
|
|
46
|
+
# the progress bar
|
|
47
|
+
progress = ctk.CTkProgressBar(dialog, width=440, mode="indeterminate")
|
|
48
|
+
|
|
49
|
+
btn_frame = ctk.CTkFrame(dialog, fg_color="transparent")
|
|
50
|
+
btn_frame.pack(pady=12)
|
|
51
|
+
|
|
52
|
+
# helper function to update the status label and color
|
|
53
|
+
def _set_status(msg, color="gray70"):
|
|
54
|
+
status_label.configure(text=msg, text_color=color)
|
|
55
|
+
|
|
56
|
+
# helper function to re-enable the UI elements after a download attempt (whether successful or not)
|
|
57
|
+
def _re_enable():
|
|
58
|
+
progress.stop()
|
|
59
|
+
progress.pack_forget()
|
|
60
|
+
btn_download.configure(state="normal")
|
|
61
|
+
btn_cancel.configure(state="normal")
|
|
62
|
+
url_entry.configure(state="normal")
|
|
63
|
+
|
|
64
|
+
"""Background thread: download → validate → callback."""
|
|
65
|
+
def _do_download():
|
|
66
|
+
url = url_entry.get().strip()
|
|
67
|
+
# if the URL field is empty, show an error and re-enable the UI so they can try again
|
|
68
|
+
if not url:
|
|
69
|
+
dialog.after(0, lambda: _set_status("Please enter a URL.", "red"))
|
|
70
|
+
dialog.after(0, _re_enable)
|
|
71
|
+
return
|
|
72
|
+
# if the URL doesn't start with http:// or https://, show an error and re-enable the UI so they can try again
|
|
73
|
+
if not url.startswith(("http://", "https://")):
|
|
74
|
+
dialog.after(0, lambda: _set_status("URL must start with http:// or https://", "red"))
|
|
75
|
+
dialog.after(0, _re_enable)
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
filename = _sanitize_filename(url)
|
|
79
|
+
filepath = os.path.join(functionality.LIBARY_DIR, filename)
|
|
80
|
+
# try downloading the file from the URL to a temp location
|
|
81
|
+
try:
|
|
82
|
+
urllib.request.urlretrieve(url, filepath)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
dialog.after(0, lambda err=e: _set_status(f"Download failed: {err}", "red"))
|
|
85
|
+
dialog.after(0, _re_enable)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
# open the downloaded file and check if it has a valid PDF header, if not it's not a valid PDF so we delete the file
|
|
89
|
+
try:
|
|
90
|
+
# open the pdf file as binary mode and read the first 5 bytes to check for the PDF header "%PDF-"
|
|
91
|
+
with open(filepath, "rb") as f:
|
|
92
|
+
header = f.read(5)
|
|
93
|
+
if header != b"%PDF-":
|
|
94
|
+
os.remove(filepath)
|
|
95
|
+
dialog.after(0, lambda: _set_status("That URL did not return a PDF file.", "red"))
|
|
96
|
+
dialog.after(0, _re_enable)
|
|
97
|
+
return
|
|
98
|
+
except Exception as e:
|
|
99
|
+
dialog.after(0, lambda err=e: _set_status(f"Error reading file: {err}", "red"))
|
|
100
|
+
dialog.after(0, _re_enable)
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
# schedule _finish to run on the main thread as soon as it is free
|
|
104
|
+
dialog.after(0, lambda fp=filepath, fn=filename: _finish(fp, fn))
|
|
105
|
+
|
|
106
|
+
# helper function to clean up the dialog and call the on_success callback with the downloaded file path and name
|
|
107
|
+
def _finish(filepath, filename):
|
|
108
|
+
# stop the progress bar
|
|
109
|
+
progress.stop()
|
|
110
|
+
# hide it
|
|
111
|
+
progress.pack_forget()
|
|
112
|
+
# grab_release and destroy the dialog before calling on_success to avoid any potential issues if on_success takes a long time to execute or opens another dialog
|
|
113
|
+
dialog.grab_release()
|
|
114
|
+
dialog.destroy()
|
|
115
|
+
on_success(filepath, filename)
|
|
116
|
+
|
|
117
|
+
# helper function to start the download thread and update the UI accordingly
|
|
118
|
+
def _start():
|
|
119
|
+
url = url_entry.get().strip()
|
|
120
|
+
if not url:
|
|
121
|
+
_set_status("Please enter a URL.", "red")
|
|
122
|
+
return
|
|
123
|
+
btn_download.configure(state="disabled")
|
|
124
|
+
btn_cancel.configure(state="disabled")
|
|
125
|
+
url_entry.configure(state="disabled")
|
|
126
|
+
progress.pack(padx=20, pady=(0, 4))
|
|
127
|
+
progress.start()
|
|
128
|
+
_set_status("Downloading…", "gray70")
|
|
129
|
+
threading.Thread(target=_do_download, daemon=True).start()
|
|
130
|
+
|
|
131
|
+
btn_download = ctk.CTkButton(
|
|
132
|
+
btn_frame,
|
|
133
|
+
text="Download & Open",
|
|
134
|
+
font=my_font,
|
|
135
|
+
command=_start
|
|
136
|
+
)
|
|
137
|
+
btn_download.pack(side="left", padx=8)
|
|
138
|
+
|
|
139
|
+
btn_cancel = ctk.CTkButton(
|
|
140
|
+
btn_frame,
|
|
141
|
+
text="Cancel",
|
|
142
|
+
font=my_font,
|
|
143
|
+
fg_color="gray40",
|
|
144
|
+
hover_color="gray30",
|
|
145
|
+
command=dialog.destroy
|
|
146
|
+
)
|
|
147
|
+
btn_cancel.pack(side="left", padx=8)
|
|
148
|
+
# allow pressing Enter to trigger the download
|
|
149
|
+
dialog.bind("<Return>", lambda e: _start())
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'''This is a shared state file that is imported by both UI.py and functionality.py'''
|
|
2
|
+
|
|
3
|
+
# stores every PDF the user has added when they press the "Add PDF" button
|
|
4
|
+
# key = the filename shown in the left panel (e.g. "mybook.pdf")
|
|
5
|
+
# value = the full file path on disk (e.g. "C:/Users/.../mybook.pdf")
|
|
6
|
+
pdf_files = {}
|
|
7
|
+
|
|
8
|
+
# stores the CTkLabel widget for each file shown in the left frame
|
|
9
|
+
# key = filename (same key as pdf_files so we can look up both together)
|
|
10
|
+
# value = the CTkLabel widget — we keep this so we can highlight it when the user clicks it, and destroy it when the user removes the file
|
|
11
|
+
file_labels = {}
|
|
12
|
+
|
|
13
|
+
# currently selected filename
|
|
14
|
+
selected_file = None
|
|
15
|
+
|
|
16
|
+
# the fitz Document object for the PDF that is currently open and being viewed
|
|
17
|
+
# fitz.open() returns this so it gives us access to page count, page sizes, and lets us render individual pages to pixel data
|
|
18
|
+
doc = None # open fitz.Document
|
|
19
|
+
|
|
20
|
+
# widget refs set by UI.py
|
|
21
|
+
app = None
|
|
22
|
+
# the CTkCanvas on the right side where we render PDF pages
|
|
23
|
+
pdf_container = None
|
|
24
|
+
# the CTkLabel on the left side that shows the filename
|
|
25
|
+
file_list = None
|
|
26
|
+
# the CTkEntry in the top-right where the user can type a page number to jump to
|
|
27
|
+
page_entry = None
|
|
28
|
+
# the CTkLabel in the top-right that shows "Page X / Y"
|
|
29
|
+
page_total_label = None
|
|
30
|
+
# store the user last read page when they close the app so it stay there
|
|
31
|
+
last_read_pages = {}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import customtkinter as ctk
|
|
2
|
+
from PIL import Image
|
|
3
|
+
from . import Data, functionality
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
from .functionality import handle_add_pdf, open_pdf, remove_pdf, zoom_in, zoom_out, poll_scroll, set_canvas, prev_page, next_page, handle_add_url, jump_to_entered_page
|
|
6
|
+
|
|
7
|
+
def build(app):
|
|
8
|
+
Data.app = app
|
|
9
|
+
# the font we use for all buttons and Labels in the UI
|
|
10
|
+
my_font = ctk.CTkFont(family="Arial", size=15, weight="bold")
|
|
11
|
+
|
|
12
|
+
# the top frame
|
|
13
|
+
top_frame = ctk.CTkFrame(app)
|
|
14
|
+
top_frame.pack(fill="x", padx=10, pady=5)
|
|
15
|
+
# helper function to add icons to the buttons
|
|
16
|
+
def make_icon(filename):
|
|
17
|
+
path = files("pdfreader.assets.button_icon").joinpath(filename)
|
|
18
|
+
img = Image.open(path)
|
|
19
|
+
return ctk.CTkImage(
|
|
20
|
+
light_image=img,
|
|
21
|
+
dark_image=img,
|
|
22
|
+
size=(20, 20)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# add pdf button
|
|
26
|
+
btn_add = ctk.CTkButton(
|
|
27
|
+
top_frame,
|
|
28
|
+
text="Add PDF",
|
|
29
|
+
image=make_icon("add.png"),
|
|
30
|
+
compound="left",
|
|
31
|
+
command=handle_add_pdf,
|
|
32
|
+
font=my_font,
|
|
33
|
+
fg_color="#a51f1f",
|
|
34
|
+
hover_color="#145a8a"
|
|
35
|
+
)
|
|
36
|
+
btn_add.pack(side="left", padx=5)
|
|
37
|
+
|
|
38
|
+
btn_url = ctk.CTkButton(
|
|
39
|
+
top_frame,
|
|
40
|
+
text="From URL",
|
|
41
|
+
image=make_icon("url.png"),
|
|
42
|
+
compound="left",
|
|
43
|
+
command=handle_add_url,
|
|
44
|
+
font=my_font,
|
|
45
|
+
fg_color="#a51f1f",
|
|
46
|
+
hover_color="#145a8a"
|
|
47
|
+
)
|
|
48
|
+
btn_url.pack(side="left", padx=5)
|
|
49
|
+
|
|
50
|
+
# open button
|
|
51
|
+
btn_open = ctk.CTkButton(
|
|
52
|
+
top_frame,
|
|
53
|
+
text="Open",
|
|
54
|
+
image=make_icon("open.png"),
|
|
55
|
+
compound="left",
|
|
56
|
+
command=open_pdf,
|
|
57
|
+
font=my_font,
|
|
58
|
+
fg_color="#a51f1f",
|
|
59
|
+
hover_color="#145a8a"
|
|
60
|
+
)
|
|
61
|
+
btn_open.pack(side="left", padx=5)
|
|
62
|
+
|
|
63
|
+
# remove button
|
|
64
|
+
btn_remove = ctk.CTkButton(
|
|
65
|
+
top_frame,
|
|
66
|
+
text="Remove",
|
|
67
|
+
image=make_icon("remove.png"),
|
|
68
|
+
compound="left",
|
|
69
|
+
command=remove_pdf,
|
|
70
|
+
font=my_font,
|
|
71
|
+
fg_color="#a51f1f",
|
|
72
|
+
hover_color="#145a8a"
|
|
73
|
+
)
|
|
74
|
+
btn_remove.pack(side="left", padx=5)
|
|
75
|
+
|
|
76
|
+
# zoom in button
|
|
77
|
+
btn_zoom_in = ctk.CTkButton(
|
|
78
|
+
top_frame,
|
|
79
|
+
text="Zoom",
|
|
80
|
+
image=make_icon("zoom_in.png"),
|
|
81
|
+
compound="left",
|
|
82
|
+
command=zoom_in,
|
|
83
|
+
font=my_font,
|
|
84
|
+
fg_color="#55a51f",
|
|
85
|
+
hover_color="#145a8a"
|
|
86
|
+
)
|
|
87
|
+
btn_zoom_in.pack(side="right", padx=5)
|
|
88
|
+
|
|
89
|
+
# zoom out button
|
|
90
|
+
btn_zoom_out = ctk.CTkButton(
|
|
91
|
+
top_frame,
|
|
92
|
+
text="Zoom",
|
|
93
|
+
image=make_icon("zoom_out.png"),
|
|
94
|
+
compound="left",
|
|
95
|
+
command=zoom_out,
|
|
96
|
+
font=my_font,
|
|
97
|
+
fg_color="#55a51f",
|
|
98
|
+
hover_color="#145a8a"
|
|
99
|
+
)
|
|
100
|
+
btn_zoom_out.pack(side="right", padx=5)
|
|
101
|
+
|
|
102
|
+
# separator gap between zoom and page nav
|
|
103
|
+
ctk.CTkLabel(top_frame, text="", width=10).pack(side="right")
|
|
104
|
+
|
|
105
|
+
# next page button ▶
|
|
106
|
+
btn_next = ctk.CTkButton(
|
|
107
|
+
top_frame,
|
|
108
|
+
text="▶",
|
|
109
|
+
width=40,
|
|
110
|
+
command=next_page,
|
|
111
|
+
font=my_font,
|
|
112
|
+
fg_color="#a57f1f",
|
|
113
|
+
hover_color="#145a8a"
|
|
114
|
+
)
|
|
115
|
+
btn_next.pack(side="right", padx=2)
|
|
116
|
+
|
|
117
|
+
# page input frame: [ entry ] / N
|
|
118
|
+
page_nav_frame = ctk.CTkFrame(top_frame, fg_color="transparent")
|
|
119
|
+
page_nav_frame.pack(side="right", padx=4)
|
|
120
|
+
|
|
121
|
+
# the editable page number entry
|
|
122
|
+
Data.page_entry = ctk.CTkEntry(
|
|
123
|
+
page_nav_frame,
|
|
124
|
+
font=my_font,
|
|
125
|
+
width=52,
|
|
126
|
+
justify="center"
|
|
127
|
+
)
|
|
128
|
+
Data.page_entry.pack(side="left")
|
|
129
|
+
Data.page_entry.bind("<Return>", lambda e: functionality.jump_to_entered_page())
|
|
130
|
+
Data.page_entry.bind("<FocusOut>", lambda e: functionality.jump_to_entered_page())
|
|
131
|
+
|
|
132
|
+
# the " / N" total pages label next to the entry
|
|
133
|
+
Data.page_total_label = ctk.CTkLabel(
|
|
134
|
+
page_nav_frame,
|
|
135
|
+
text="/ --",
|
|
136
|
+
font=my_font,
|
|
137
|
+
anchor="w",
|
|
138
|
+
width=50
|
|
139
|
+
)
|
|
140
|
+
Data.page_total_label.pack(side="left", padx=(4, 0))
|
|
141
|
+
|
|
142
|
+
# prev page button ◀
|
|
143
|
+
btn_prev = ctk.CTkButton(
|
|
144
|
+
top_frame,
|
|
145
|
+
text="◀",
|
|
146
|
+
width=40,
|
|
147
|
+
command=prev_page,
|
|
148
|
+
font=my_font,
|
|
149
|
+
fg_color="#a57f1f",
|
|
150
|
+
hover_color="#145a8a"
|
|
151
|
+
)
|
|
152
|
+
btn_prev.pack(side="right", padx=2)
|
|
153
|
+
|
|
154
|
+
# content frame
|
|
155
|
+
content_frame = ctk.CTkFrame(app)
|
|
156
|
+
content_frame.pack(fill="both", expand=True, padx=10, pady=5)
|
|
157
|
+
|
|
158
|
+
# left frame
|
|
159
|
+
left_frame = ctk.CTkFrame(
|
|
160
|
+
content_frame,
|
|
161
|
+
fg_color="gray30",
|
|
162
|
+
corner_radius=8,
|
|
163
|
+
width=200
|
|
164
|
+
)
|
|
165
|
+
left_frame.pack(side="left", fill="y", padx=(5, 0), pady=0)
|
|
166
|
+
left_frame.pack_propagate(False)
|
|
167
|
+
|
|
168
|
+
# inner frame sits inside with a small margin, creating the border effect
|
|
169
|
+
inner_frame = ctk.CTkFrame(left_frame, corner_radius=6)
|
|
170
|
+
inner_frame.pack(fill="both", expand=True, padx=2, pady=2)
|
|
171
|
+
|
|
172
|
+
# header row with icon and title
|
|
173
|
+
header_frame = ctk.CTkFrame(inner_frame, fg_color="transparent")
|
|
174
|
+
header_frame.pack(fill="x", padx=8, pady=(8, 4))
|
|
175
|
+
|
|
176
|
+
ctk.CTkLabel(
|
|
177
|
+
header_frame,
|
|
178
|
+
text=" My Files",
|
|
179
|
+
font=my_font,
|
|
180
|
+
anchor="w",
|
|
181
|
+
image=make_icon("folder.png"),
|
|
182
|
+
compound="left"
|
|
183
|
+
).pack(side="left")
|
|
184
|
+
|
|
185
|
+
# thin separator line below the header
|
|
186
|
+
ctk.CTkFrame(inner_frame, height=2, fg_color="gray40").pack(fill="x", padx=6, pady=(0, 4))
|
|
187
|
+
|
|
188
|
+
Data.file_list = ctk.CTkScrollableFrame(inner_frame, fg_color="transparent")
|
|
189
|
+
Data.file_list.pack(fill="both", expand=True, padx=2, pady=(0, 2))
|
|
190
|
+
|
|
191
|
+
# right frame
|
|
192
|
+
right_frame = ctk.CTkFrame(content_frame)
|
|
193
|
+
right_frame.pack(side="right", fill="both", expand=True, padx=5)
|
|
194
|
+
|
|
195
|
+
scrollbar = ctk.CTkScrollbar(right_frame)
|
|
196
|
+
scrollbar.pack(side="right", fill="y")
|
|
197
|
+
|
|
198
|
+
# background colour matches CTk dark/light mode reasonably well
|
|
199
|
+
canvas = ctk.CTkCanvas(
|
|
200
|
+
right_frame,
|
|
201
|
+
bg="#2b2b2b",
|
|
202
|
+
highlightthickness=0,
|
|
203
|
+
yscrollcommand=scrollbar.set
|
|
204
|
+
)
|
|
205
|
+
canvas.pack(side="left", fill="both", expand=True)
|
|
206
|
+
scrollbar.configure(command=canvas.yview)
|
|
207
|
+
|
|
208
|
+
# mouse wheel scrolling
|
|
209
|
+
def _on_mousewheel(event):
|
|
210
|
+
canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
|
|
211
|
+
|
|
212
|
+
# Windows and MacOS
|
|
213
|
+
canvas.bind("<MouseWheel>", _on_mousewheel)
|
|
214
|
+
# Linux
|
|
215
|
+
canvas.bind("<Button-4>", lambda e: canvas.yview_scroll(-1, "units"))
|
|
216
|
+
canvas.bind("<Button-5>", lambda e: canvas.yview_scroll( 1, "units"))
|
|
217
|
+
|
|
218
|
+
# hand the canvas to functionality.py
|
|
219
|
+
set_canvas(canvas)
|
|
220
|
+
|
|
221
|
+
# start the scroll polling loop
|
|
222
|
+
Data.app.after(100, poll_scroll)
|
|
File without changes
|
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import os
|
|
3
|
+
import threading
|
|
4
|
+
import queue
|
|
5
|
+
import fitz
|
|
6
|
+
from PIL import Image, ImageTk
|
|
7
|
+
import customtkinter as ctk
|
|
8
|
+
from ..openDialog.AddFile import add_pdf
|
|
9
|
+
from ..openDialog.Addurl import open_url_dialog
|
|
10
|
+
from . import Data
|
|
11
|
+
|
|
12
|
+
# this dir is set by UI.py when the app start, it is where all the pdf files are stored permanently
|
|
13
|
+
LIBARY_DIR = None
|
|
14
|
+
# the default zoom level or initial value
|
|
15
|
+
zoom_level = 1.0
|
|
16
|
+
# True if the user has manually zoomed; False means auto-fit on open/resize
|
|
17
|
+
_zoom_manual = False
|
|
18
|
+
# every time a user open a new file or zoom this generation go up by one
|
|
19
|
+
# the worker thread compares its saved generation against this value, so if they are different outdated and thrown away without displaying
|
|
20
|
+
render_generation = 0
|
|
21
|
+
|
|
22
|
+
# store the id returned by app.after() for the zoom debounce timer
|
|
23
|
+
# we keep it so we can cancel the previous timer if the user click zoom again
|
|
24
|
+
_zoom_after_id = None
|
|
25
|
+
# the scroll position we saw last time poll_scroll ran
|
|
26
|
+
# we compare against this to avoid calling check_visible_page every 100ms
|
|
27
|
+
# when the user isn't scrolling
|
|
28
|
+
_last_scroll_pos = None
|
|
29
|
+
|
|
30
|
+
# tracks the page number
|
|
31
|
+
_current_page = 0
|
|
32
|
+
|
|
33
|
+
# the vertical gap in pixels between pages on the canvas
|
|
34
|
+
PAGE_GAP = 10
|
|
35
|
+
# render this many pixels above/below the visible area
|
|
36
|
+
BUFFER_PX = 600
|
|
37
|
+
# unload pages this far outside the visible area
|
|
38
|
+
UNLOAD_PX = 1200
|
|
39
|
+
|
|
40
|
+
# canvas widget stored here so the whole module can reach it
|
|
41
|
+
_canvas = None
|
|
42
|
+
|
|
43
|
+
# stores the position and size of every page on the canvas
|
|
44
|
+
# key = page number (0-indexed)
|
|
45
|
+
# value = (x, y, w, h) — top-left corner and size in canvas pixels
|
|
46
|
+
# built once in _rebuild() and updated in _on_canvas_resize(
|
|
47
|
+
_page_rects = {}
|
|
48
|
+
|
|
49
|
+
# canvas image item ids (page_num → canvas item id of the rendered image)
|
|
50
|
+
_image_items = {}
|
|
51
|
+
|
|
52
|
+
# PhotoImage refs — must be kept alive or tkinter GCs them
|
|
53
|
+
_photo_refs = {}
|
|
54
|
+
|
|
55
|
+
# the queue that check_visible_pages pushes work onto and _worker pulls from
|
|
56
|
+
# using a queue means the worker processes pages one at a time in order,
|
|
57
|
+
# instead of spawning a new thread per page which causes memory spikes
|
|
58
|
+
_render_queue = queue.Queue()
|
|
59
|
+
|
|
60
|
+
# tracks which page numbers are currently sitting inside _render_queue
|
|
61
|
+
# so _enqueue() never adds the same page twice while it's still waiting
|
|
62
|
+
_queued_pages = set()
|
|
63
|
+
|
|
64
|
+
# a lock that protects _queued_pages from being read/written simultaneously
|
|
65
|
+
# by the main thread (_enqueue) and the worker thread (_worker)
|
|
66
|
+
_queued_lock = threading.Lock()
|
|
67
|
+
|
|
68
|
+
def _worker():
|
|
69
|
+
# this loop lives forever inside the app
|
|
70
|
+
while True:
|
|
71
|
+
# block here until check_visible_pages put a page onto the queue
|
|
72
|
+
page_num, gen = _render_queue.get()
|
|
73
|
+
try:
|
|
74
|
+
# remove from the queued set so it can be re-queued later if needed
|
|
75
|
+
with _queued_lock:
|
|
76
|
+
_queued_pages.discard(page_num)
|
|
77
|
+
# if render_generation change or there's nothing in the doc skip it
|
|
78
|
+
if gen != render_generation or Data.doc is None:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
# render the page
|
|
82
|
+
page = Data.doc[page_num]
|
|
83
|
+
pix = page.get_pixmap(matrix=fitz.Matrix(zoom_level, zoom_level))
|
|
84
|
+
# convert to PIL
|
|
85
|
+
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
|
|
86
|
+
del pix
|
|
87
|
+
# check if it is still in the same generation, if so app.after(0, fn) queues fn to run as soon as the main thread is free, which is the safe way to update the UI from a thread
|
|
88
|
+
if gen == render_generation:
|
|
89
|
+
Data.app.after(0, lambda i=img, n=page_num, g=gen: _show_page(i, n, g))
|
|
90
|
+
else:
|
|
91
|
+
del img
|
|
92
|
+
except Exception as e:
|
|
93
|
+
print(f"Render error page {page_num}: {e}")
|
|
94
|
+
finally:
|
|
95
|
+
# always call task_done so queue.join() work correctly if ever used
|
|
96
|
+
_render_queue.task_done()
|
|
97
|
+
|
|
98
|
+
# start the worker thread
|
|
99
|
+
threading.Thread(target=_worker, daemon=True).start()
|
|
100
|
+
|
|
101
|
+
def _enqueue(page_num, gen):
|
|
102
|
+
# add a page to the render queue
|
|
103
|
+
with _queued_lock:
|
|
104
|
+
# if the page is already in the queue return
|
|
105
|
+
if page_num in _queued_pages:
|
|
106
|
+
return
|
|
107
|
+
# mark is as queue before releasing the lock
|
|
108
|
+
_queued_pages.add(page_num)
|
|
109
|
+
|
|
110
|
+
_render_queue.put((page_num, gen))
|
|
111
|
+
|
|
112
|
+
# empty the render queue
|
|
113
|
+
def _drain():
|
|
114
|
+
# clear the tracking set so _enqueue accept new pages immediately
|
|
115
|
+
with _queued_lock:
|
|
116
|
+
_queued_pages.clear()
|
|
117
|
+
# pull everything out of the queue without processing it
|
|
118
|
+
while not _render_queue.empty():
|
|
119
|
+
try:
|
|
120
|
+
_render_queue.get_nowait()
|
|
121
|
+
_render_queue.task_done()
|
|
122
|
+
except queue.Empty:
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
# update the page entry and total label in the top bar
|
|
126
|
+
def _update_page_label():
|
|
127
|
+
if Data.page_entry is None:
|
|
128
|
+
return
|
|
129
|
+
if Data.doc is None:
|
|
130
|
+
Data.page_entry.delete(0, "end")
|
|
131
|
+
Data.page_total_label.configure(text="/ --")
|
|
132
|
+
return
|
|
133
|
+
total = len(Data.doc)
|
|
134
|
+
# update the entry to show the current page number
|
|
135
|
+
Data.page_entry.delete(0, "end")
|
|
136
|
+
Data.page_entry.insert(0, str(_current_page + 1))
|
|
137
|
+
# update the " / N" label
|
|
138
|
+
Data.page_total_label.configure(text=f"/ {total}")
|
|
139
|
+
|
|
140
|
+
# called when the user presses Enter or clicks away from the page entry
|
|
141
|
+
def jump_to_entered_page():
|
|
142
|
+
if Data.doc is None or Data.page_entry is None:
|
|
143
|
+
return
|
|
144
|
+
raw = Data.page_entry.get().strip()
|
|
145
|
+
total = len(Data.doc)
|
|
146
|
+
# if empty or invalid, fall back to page 1
|
|
147
|
+
try:
|
|
148
|
+
page = int(raw)
|
|
149
|
+
if page < 1 or page > total:
|
|
150
|
+
raise ValueError
|
|
151
|
+
except ValueError:
|
|
152
|
+
page = 1
|
|
153
|
+
# update the entry to show the corrected value
|
|
154
|
+
Data.page_entry.delete(0, "end")
|
|
155
|
+
Data.page_entry.insert(0, str(page))
|
|
156
|
+
go_to_page(page - 1)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Copy src_filepath into LIBRARY_DIR and return the new permanent path
|
|
160
|
+
def _copy_to_library(src_filepath, filename):
|
|
161
|
+
dest = os.path.join(LIBARY_DIR, filename)
|
|
162
|
+
# if the abosulute path of the src and dest are the same, it means the file is already in the library so we don't need to copy it, just return the path
|
|
163
|
+
if os.path.abspath(src_filepath) != os.path.abspath(dest):
|
|
164
|
+
shutil.copy2(src_filepath, dest)
|
|
165
|
+
return dest
|
|
166
|
+
|
|
167
|
+
# if the filename already exist in the library, we add (1), (2) etc before the extension until we find a unique name
|
|
168
|
+
def _unique_filename(filename):
|
|
169
|
+
if "." in filename:
|
|
170
|
+
# base is the filename without extension, ext is the extension with dot (e.g "book.pdf" -> base="book", ext="pdf") because we split "."
|
|
171
|
+
base, ext = filename.rsplit(".", 1)
|
|
172
|
+
# add the "." back to the extension we split off
|
|
173
|
+
ext = "." + ext
|
|
174
|
+
else:
|
|
175
|
+
# else we just treat the whole filename as base and extension is empty
|
|
176
|
+
base, ext = filename, ""
|
|
177
|
+
unique = filename
|
|
178
|
+
counter = 1
|
|
179
|
+
# while the unique name is already a key in the pdf_files dict, generate a new name by adding (counter) before the extension and increment the counter
|
|
180
|
+
while unique in Data.pdf_files:
|
|
181
|
+
unique = f"{base} ({counter}){ext}"
|
|
182
|
+
counter += 1
|
|
183
|
+
return unique
|
|
184
|
+
|
|
185
|
+
# this is called by both handle_add_pdf and the URL dialog when the user submit a URL, it takes care of copying the file to library,
|
|
186
|
+
# adding it to the left panel, and registering the click event to select the file when clicked
|
|
187
|
+
def _register_file(filepath, filename):
|
|
188
|
+
filename = _unique_filename(filename)
|
|
189
|
+
perm_path = _copy_to_library(filepath, filename)
|
|
190
|
+
Data.pdf_files[filename] = perm_path
|
|
191
|
+
label = ctk.CTkLabel(Data.file_list, text=filename)
|
|
192
|
+
label.pack(anchor="w", padx=5, pady=2)
|
|
193
|
+
Data.file_labels[filename] = label
|
|
194
|
+
label.bind("<Button-1>", lambda e, name=filename: select_file(name))
|
|
195
|
+
|
|
196
|
+
# function to handle pdf when user click add pdf button
|
|
197
|
+
def handle_add_pdf():
|
|
198
|
+
# return the file path and name as filepath and filename
|
|
199
|
+
filepath, filename = add_pdf()
|
|
200
|
+
if not filepath:
|
|
201
|
+
return
|
|
202
|
+
_register_file(filepath, filename)
|
|
203
|
+
|
|
204
|
+
# function to handle url when user click add url button
|
|
205
|
+
def handle_add_url():
|
|
206
|
+
open_url_dialog(Data.app, _register_file)
|
|
207
|
+
|
|
208
|
+
# when user clicks a file it saves which file is selected print it
|
|
209
|
+
def select_file(filename):
|
|
210
|
+
Data.selected_file = filename
|
|
211
|
+
for lbl in Data.file_labels.values():
|
|
212
|
+
lbl.configure(fg_color="transparent")
|
|
213
|
+
# highlights the file text in gray background
|
|
214
|
+
Data.file_labels[filename].configure(fg_color="gray")
|
|
215
|
+
print(f"selected: {filename}")
|
|
216
|
+
|
|
217
|
+
# when user click zoom in it increase the zoom level by 20%
|
|
218
|
+
def zoom_in():
|
|
219
|
+
global zoom_level, _zoom_after_id, _zoom_manual
|
|
220
|
+
_zoom_manual = True
|
|
221
|
+
zoom_level += 0.2
|
|
222
|
+
# debounced so rapid clicks only trigger one render
|
|
223
|
+
if _zoom_after_id:
|
|
224
|
+
Data.app.after_cancel(_zoom_after_id)
|
|
225
|
+
_zoom_after_id = Data.app.after(300, _rebuild)
|
|
226
|
+
|
|
227
|
+
# when user click zoom out it decrease the zoom level by 20% but it doesn't go beyond 40%
|
|
228
|
+
def zoom_out():
|
|
229
|
+
global zoom_level, _zoom_after_id, _zoom_manual
|
|
230
|
+
_zoom_manual = True
|
|
231
|
+
zoom_level = max(0.4, zoom_level - 0.2)
|
|
232
|
+
if _zoom_after_id:
|
|
233
|
+
Data.app.after_cancel(_zoom_after_id)
|
|
234
|
+
_zoom_after_id = Data.app.after(300, _rebuild)
|
|
235
|
+
|
|
236
|
+
# calculate the zoom level that makes the first page fill the canvas width
|
|
237
|
+
def _fit_zoom_to_canvas():
|
|
238
|
+
global zoom_level
|
|
239
|
+
if Data.doc is None or _canvas is None:
|
|
240
|
+
return
|
|
241
|
+
canvas_w = _canvas.winfo_width()
|
|
242
|
+
if canvas_w < 10:
|
|
243
|
+
canvas_w = 800 # fallback before canvas is fully laid out
|
|
244
|
+
page_w = Data.doc[0].rect.width
|
|
245
|
+
if page_w > 0:
|
|
246
|
+
zoom_level = (canvas_w - 20) / page_w # 10px padding each side
|
|
247
|
+
|
|
248
|
+
# when user clicks open it check if there is a file selected
|
|
249
|
+
def open_pdf():
|
|
250
|
+
global _zoom_manual
|
|
251
|
+
if not Data.selected_file:
|
|
252
|
+
print("No file selected")
|
|
253
|
+
return
|
|
254
|
+
# close the previous document if there is one
|
|
255
|
+
if Data.doc:
|
|
256
|
+
Data.doc.close()
|
|
257
|
+
Data.doc = fitz.open(Data.pdf_files[Data.selected_file])
|
|
258
|
+
# reset manual zoom so we auto-fit on open
|
|
259
|
+
_zoom_manual = False
|
|
260
|
+
_fit_zoom_to_canvas()
|
|
261
|
+
# draw the page placeholders and start lazy rendering
|
|
262
|
+
_rebuild()
|
|
263
|
+
# restore the last read page after a short delay so _rebuild finish first
|
|
264
|
+
saved_page = Data.last_read_pages.get(Data.selected_file, 0)
|
|
265
|
+
if saved_page > 0:
|
|
266
|
+
Data.app.after(150, lambda: go_to_page(saved_page))
|
|
267
|
+
|
|
268
|
+
# when user clicks remove it checks if there is a file selected
|
|
269
|
+
def remove_pdf():
|
|
270
|
+
if not Data.selected_file:
|
|
271
|
+
print("No file selected")
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
filepath = Data.pdf_files[Data.selected_file]
|
|
275
|
+
|
|
276
|
+
if Data.doc:
|
|
277
|
+
Data.doc.close()
|
|
278
|
+
Data.doc = None
|
|
279
|
+
# try to remove the file from the disk
|
|
280
|
+
try:
|
|
281
|
+
if os.path.exists(filepath):
|
|
282
|
+
os.remove(filepath)
|
|
283
|
+
except Exception as e:
|
|
284
|
+
print(f"Error removing file: {e}")
|
|
285
|
+
|
|
286
|
+
# remove the file from the pdf_file
|
|
287
|
+
del Data.pdf_files[Data.selected_file]
|
|
288
|
+
# destroy the label widget in the left frame
|
|
289
|
+
Data.file_labels[Data.selected_file].destroy()
|
|
290
|
+
# then delete it fromn the file_labels
|
|
291
|
+
del Data.file_labels[Data.selected_file]
|
|
292
|
+
# then clear the selected file
|
|
293
|
+
Data.selected_file = None
|
|
294
|
+
|
|
295
|
+
# wipe everything off the canvas
|
|
296
|
+
_clear_canvas()
|
|
297
|
+
# clear the page label
|
|
298
|
+
_update_page_label()
|
|
299
|
+
|
|
300
|
+
# (only called once by the UI.py once it created the canvas widget) it is use to store canvas reference and bind the resize event
|
|
301
|
+
def set_canvas(canvas):
|
|
302
|
+
global _canvas
|
|
303
|
+
_canvas = canvas
|
|
304
|
+
# when the user resize the window, recenter all the page rectangle
|
|
305
|
+
_canvas.bind("<Configure>", lambda e: _on_canvas_resize(e))
|
|
306
|
+
|
|
307
|
+
# scroll the canvas so that the top of page_num (0-indexed) is in view
|
|
308
|
+
def go_to_page(page_num):
|
|
309
|
+
if Data.doc is None or not _page_rects:
|
|
310
|
+
return
|
|
311
|
+
# clamp to valid range
|
|
312
|
+
page_num = max(0, min(page_num, len(Data.doc) - 1))
|
|
313
|
+
if page_num not in _page_rects:
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
sr = _canvas.cget("scrollregion")
|
|
317
|
+
try:
|
|
318
|
+
total_h = float(sr.split()[3]) if sr else _canvas.winfo_height()
|
|
319
|
+
except Exception:
|
|
320
|
+
total_h = _canvas.winfo_height()
|
|
321
|
+
if total_h <= 0:
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
_, y, _, _ = _page_rects[page_num]
|
|
325
|
+
# scroll so the top of the page is at the top of the visible area
|
|
326
|
+
fraction = y / total_h
|
|
327
|
+
_canvas.yview_moveto(fraction)
|
|
328
|
+
|
|
329
|
+
# go to the previous page relative to the current visible page
|
|
330
|
+
def prev_page():
|
|
331
|
+
go_to_page(_current_page - 1)
|
|
332
|
+
|
|
333
|
+
# go to the next page relative to the current visible page
|
|
334
|
+
def next_page():
|
|
335
|
+
go_to_page(_current_page + 1)
|
|
336
|
+
|
|
337
|
+
# when the window resizes, reposition all page rects to stay centred
|
|
338
|
+
# if the user hasn't manually zoomed, re-fit the zoom to the new canvas width
|
|
339
|
+
def _on_canvas_resize(e):
|
|
340
|
+
# if there is no page to loaded yet, return
|
|
341
|
+
if not _page_rects:
|
|
342
|
+
return
|
|
343
|
+
# the new canvas width in pixels
|
|
344
|
+
canvas_w = e.width
|
|
345
|
+
|
|
346
|
+
# if auto-fit mode, recalculate zoom and fully rebuild at the new scale
|
|
347
|
+
if not _zoom_manual and Data.doc is not None:
|
|
348
|
+
_fit_zoom_to_canvas()
|
|
349
|
+
_rebuild()
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
for page_num, (x, y, w, h) in list(_page_rects.items()):
|
|
353
|
+
# calculate the new x so the page is centered in the canvas
|
|
354
|
+
new_x = max(0, (canvas_w - w) // 2)
|
|
355
|
+
# update the stored rect with the new x position
|
|
356
|
+
_page_rects[page_num] = (new_x, y, w, h)
|
|
357
|
+
# move the gray background rectangle on canvas to a new position
|
|
358
|
+
tag = f"bg_{page_num}"
|
|
359
|
+
_canvas.coords(tag, new_x, y, new_x + w, y + h)
|
|
360
|
+
# if this page has a rendered image on the canvas, move it too
|
|
361
|
+
if page_num in _image_items:
|
|
362
|
+
_canvas.coords(_image_items[page_num], new_x + w // 2, y + h // 2)
|
|
363
|
+
# re-checked which page are visible after the resize
|
|
364
|
+
check_visible_pages()
|
|
365
|
+
|
|
366
|
+
# delete everything from the canvas and reset all tracking dicts
|
|
367
|
+
def _clear_canvas():
|
|
368
|
+
global _page_rects, _image_items, _photo_refs
|
|
369
|
+
if _canvas:
|
|
370
|
+
_canvas.delete("all")
|
|
371
|
+
_page_rects.clear()
|
|
372
|
+
_image_items.clear()
|
|
373
|
+
_photo_refs.clear()
|
|
374
|
+
|
|
375
|
+
# redraws all page placeholder retangles on the canvas (called when opening a new pdf or zooming)
|
|
376
|
+
def _rebuild():
|
|
377
|
+
global render_generation, _current_page
|
|
378
|
+
render_generation += 1
|
|
379
|
+
_drain()
|
|
380
|
+
_clear_canvas()
|
|
381
|
+
# check if there's no document is open or canvas doesn't exist yet
|
|
382
|
+
if Data.doc is None or _canvas is None:
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
# reset to page 1 whenever we (re)build then scroll to top first
|
|
386
|
+
_current_page = 0
|
|
387
|
+
_canvas.yview_moveto(0.0)
|
|
388
|
+
|
|
389
|
+
# total number of page
|
|
390
|
+
n_pages = len(Data.doc)
|
|
391
|
+
# current canvas width, fall back 800
|
|
392
|
+
canvas_w = _canvas.winfo_width() or 800
|
|
393
|
+
|
|
394
|
+
# y track the vertical position of the next page as we stack them
|
|
395
|
+
y = PAGE_GAP
|
|
396
|
+
|
|
397
|
+
for page_num in range(n_pages):
|
|
398
|
+
r = Data.doc[page_num].rect
|
|
399
|
+
# scale the dimension by the current zoom level
|
|
400
|
+
w = int(r.width * zoom_level)
|
|
401
|
+
h = int(r.height * zoom_level)
|
|
402
|
+
|
|
403
|
+
# center the page horizontally on the canvas
|
|
404
|
+
x = max(0, (canvas_w - w) // 2)
|
|
405
|
+
|
|
406
|
+
# save the position so check_visible_page can find this page later
|
|
407
|
+
_page_rects[page_num] = (x, y, w, h)
|
|
408
|
+
|
|
409
|
+
# draw a gray rectangle as the placeholder
|
|
410
|
+
_canvas.create_rectangle(
|
|
411
|
+
x, y, x + w, y + h,
|
|
412
|
+
fill="#333333",
|
|
413
|
+
outline="#555555",
|
|
414
|
+
tags=(f"bg_{page_num}",)
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# page number label in the centre of the placeholder
|
|
418
|
+
_canvas.create_text(
|
|
419
|
+
x + w // 2, y + h // 2,
|
|
420
|
+
text=str(page_num + 1),
|
|
421
|
+
fill="#888888",
|
|
422
|
+
font=("Arial", 14),
|
|
423
|
+
tags=(f"lbl_{page_num}",)
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
y += h + PAGE_GAP
|
|
427
|
+
|
|
428
|
+
# set the canvas scroll region to the full document height
|
|
429
|
+
_canvas.configure(scrollregion=(0, 0, canvas_w, y))
|
|
430
|
+
|
|
431
|
+
# update the counter to show Page 1 / N
|
|
432
|
+
_update_page_label()
|
|
433
|
+
|
|
434
|
+
check_visible_pages()
|
|
435
|
+
|
|
436
|
+
# run every 100ms on the main thread
|
|
437
|
+
def poll_scroll():
|
|
438
|
+
global _last_scroll_pos
|
|
439
|
+
# if there is a document open and page rects to check
|
|
440
|
+
if Data.doc is not None and _page_rects:
|
|
441
|
+
# return the current scroll position as a tuple (top_fraction, bottom_fraction)
|
|
442
|
+
pos = _canvas.yview()
|
|
443
|
+
# if the scroll position changed since last time, check which page are visible and need rendering
|
|
444
|
+
if pos != _last_scroll_pos:
|
|
445
|
+
_last_scroll_pos = pos
|
|
446
|
+
check_visible_pages()
|
|
447
|
+
# schedule the next poll in 100ms
|
|
448
|
+
Data.app.after(100, poll_scroll)
|
|
449
|
+
|
|
450
|
+
# look at every page rect and decide whether to render or unload it based on its position relative to the visible area of the canvas
|
|
451
|
+
def check_visible_pages():
|
|
452
|
+
global _current_page
|
|
453
|
+
|
|
454
|
+
if Data.doc is None or not _page_rects or _canvas is None:
|
|
455
|
+
return
|
|
456
|
+
|
|
457
|
+
# visible range in canvas content pixels
|
|
458
|
+
sr = _canvas.cget("scrollregion")
|
|
459
|
+
try:
|
|
460
|
+
# if scrollregion is set it looks like "0 0 800 2400", we want the total height which is the 4th number
|
|
461
|
+
if sr:
|
|
462
|
+
total_h = float(sr.split()[3])
|
|
463
|
+
else:
|
|
464
|
+
# else we can get the canvas height in pixels
|
|
465
|
+
total_h = _canvas.winfo_height()
|
|
466
|
+
except Exception:
|
|
467
|
+
total_h = _canvas.winfo_height()
|
|
468
|
+
# if we can't get a valid total height, just return
|
|
469
|
+
if total_h <= 0:
|
|
470
|
+
return
|
|
471
|
+
# yview() return the fractions: (0.0, 0.1) means the top 10% of the canvas content is visible, we multiply by total_h to get the pixel position of the visible area
|
|
472
|
+
t, b = _canvas.yview()
|
|
473
|
+
vis_top = t * total_h
|
|
474
|
+
vis_bot = b * total_h
|
|
475
|
+
vis_mid = (vis_top + vis_bot) / 2
|
|
476
|
+
|
|
477
|
+
# find which page's centre is closest to the centre of the viewport
|
|
478
|
+
# this gives the most natural "current page" feel while scrolling
|
|
479
|
+
best_page = _current_page
|
|
480
|
+
best_dist = float("inf")
|
|
481
|
+
|
|
482
|
+
# loop through every page rect and check if it's within the buffer zone around the visible area,
|
|
483
|
+
# if so enqueue it for rendering if it's not already, if it's far outside the visible area and currently rendered, unload it to save memory
|
|
484
|
+
for page_num, (x, y, w, h) in _page_rects.items():
|
|
485
|
+
page_top = y
|
|
486
|
+
page_bot = y + h
|
|
487
|
+
page_mid = y + h / 2
|
|
488
|
+
|
|
489
|
+
in_view = page_bot + BUFFER_PX >= vis_top and page_top - BUFFER_PX <= vis_bot
|
|
490
|
+
far_away = page_bot + UNLOAD_PX < vis_top or page_top - UNLOAD_PX > vis_bot
|
|
491
|
+
|
|
492
|
+
if in_view and page_num not in _image_items:
|
|
493
|
+
_enqueue(page_num, render_generation)
|
|
494
|
+
elif far_away and page_num in _image_items:
|
|
495
|
+
_unload_page(page_num)
|
|
496
|
+
|
|
497
|
+
# track the page whose centre is closest to the viewport centre
|
|
498
|
+
dist = abs(page_mid - vis_mid)
|
|
499
|
+
if dist < best_dist:
|
|
500
|
+
best_dist = dist
|
|
501
|
+
best_page = page_num
|
|
502
|
+
|
|
503
|
+
# update counter only when the page actually changes
|
|
504
|
+
if best_page != _current_page:
|
|
505
|
+
_current_page = best_page
|
|
506
|
+
_update_page_label()
|
|
507
|
+
|
|
508
|
+
# when a page is far outside the visible area we call this to remove its image from the canvas and delete the reference so it can be garbage collected and free memory
|
|
509
|
+
def _unload_page(page_num):
|
|
510
|
+
if page_num in _image_items:
|
|
511
|
+
_canvas.delete(_image_items[page_num])
|
|
512
|
+
del _image_items[page_num]
|
|
513
|
+
if page_num in _photo_refs:
|
|
514
|
+
del _photo_refs[page_num]
|
|
515
|
+
|
|
516
|
+
# when the worker thread finish rendering a page it call this to display the image on the canvas,
|
|
517
|
+
# but only if the generation is still the same (the user might have zoomed or opened another file since then)
|
|
518
|
+
def _show_page(img, page_num, gen):
|
|
519
|
+
if gen != render_generation or _canvas is None:
|
|
520
|
+
del img
|
|
521
|
+
return
|
|
522
|
+
if page_num not in _page_rects:
|
|
523
|
+
del img
|
|
524
|
+
return
|
|
525
|
+
|
|
526
|
+
x, y, w, h = _page_rects[page_num]
|
|
527
|
+
|
|
528
|
+
photo = ImageTk.PhotoImage(img)
|
|
529
|
+
del img
|
|
530
|
+
|
|
531
|
+
# keep ref so GC doesn't collect it
|
|
532
|
+
_photo_refs[page_num] = photo
|
|
533
|
+
|
|
534
|
+
# hide the page number label
|
|
535
|
+
_canvas.delete(f"lbl_{page_num}")
|
|
536
|
+
|
|
537
|
+
# draw image centred in the placeholder rect
|
|
538
|
+
item_id = _canvas.create_image(x + w // 2, y + h // 2, image=photo, anchor="center")
|
|
539
|
+
_image_items[page_num] = item_id
|