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.
Files changed (26) hide show
  1. pdfreading_app-0.1.0/PKG-INFO +8 -0
  2. pdfreading_app-0.1.0/README.md +1 -0
  3. pdfreading_app-0.1.0/pyproject.toml +20 -0
  4. pdfreading_app-0.1.0/src/pdfreading/.gitignore +0 -0
  5. pdfreading_app-0.1.0/src/pdfreading/__init__.py +0 -0
  6. pdfreading_app-0.1.0/src/pdfreading/assets/__init__.py +0 -0
  7. pdfreading_app-0.1.0/src/pdfreading/assets/button_icon/add.png +0 -0
  8. pdfreading_app-0.1.0/src/pdfreading/assets/button_icon/folder.png +0 -0
  9. pdfreading_app-0.1.0/src/pdfreading/assets/button_icon/open.png +0 -0
  10. pdfreading_app-0.1.0/src/pdfreading/assets/button_icon/remove.png +0 -0
  11. pdfreading_app-0.1.0/src/pdfreading/assets/button_icon/url.png +0 -0
  12. pdfreading_app-0.1.0/src/pdfreading/assets/button_icon/zoom_in.png +0 -0
  13. pdfreading_app-0.1.0/src/pdfreading/assets/button_icon/zoom_out.png +0 -0
  14. pdfreading_app-0.1.0/src/pdfreading/assets/text.txt +0 -0
  15. pdfreading_app-0.1.0/src/pdfreading/assets/title_icon/book1.ico +0 -0
  16. pdfreading_app-0.1.0/src/pdfreading/assets/title_icon/book2.ico +0 -0
  17. pdfreading_app-0.1.0/src/pdfreading/assets/title_icon/book2.png +0 -0
  18. pdfreading_app-0.1.0/src/pdfreading/assets/title_icon/book3.ico +0 -0
  19. pdfreading_app-0.1.0/src/pdfreading/main.py +105 -0
  20. pdfreading_app-0.1.0/src/pdfreading/openDialog/AddFile.py +14 -0
  21. pdfreading_app-0.1.0/src/pdfreading/openDialog/Addurl.py +149 -0
  22. pdfreading_app-0.1.0/src/pdfreading/openDialog/__init__.py +0 -0
  23. pdfreading_app-0.1.0/src/pdfreading/utils/Data.py +31 -0
  24. pdfreading_app-0.1.0/src/pdfreading/utils/UI.py +222 -0
  25. pdfreading_app-0.1.0/src/pdfreading/utils/__init__.py +0 -0
  26. pdfreading_app-0.1.0/src/pdfreading/utils/functionality.py +539 -0
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: pdfreading-app
3
+ Version: 0.1.0
4
+ Summary: A PDF reader desktop app
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: customtkinter
7
+ Requires-Dist: pillow
8
+ Requires-Dist: pymupdf
@@ -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
File without changes
@@ -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())
@@ -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