├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ └── main.yml ├── src └── journal2ebook │ ├── _version.py │ ├── __init__.py │ ├── _exceptions.py │ ├── __main__.py │ ├── _config.py │ └── _window.py ├── img └── screenshot.png ├── journal2ebook_plans.org ├── LICENSE ├── .pre-commit-config.yaml ├── README.md ├── .gitignore └── pyproject.toml /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @adasilva 2 | -------------------------------------------------------------------------------- /src/journal2ebook/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.2.0" 2 | -------------------------------------------------------------------------------- /src/journal2ebook/__init__.py: -------------------------------------------------------------------------------- 1 | from ._version import __version__ 2 | -------------------------------------------------------------------------------- /src/journal2ebook/_exceptions.py: -------------------------------------------------------------------------------- 1 | class NoPdfSelectedError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adasilva/journal2ebook/HEAD/img/screenshot.png -------------------------------------------------------------------------------- /src/journal2ebook/__main__.py: -------------------------------------------------------------------------------- 1 | from ._window import main 2 | 3 | if __name__ == "__main__": 4 | main() 5 | -------------------------------------------------------------------------------- /journal2ebook_plans.org: -------------------------------------------------------------------------------- 1 | * TODO Pop-up window to ask for height, or auto-detect based on OS 2 | * TODO pass enter keypress to k2pdfopt so it runs automatically 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | groups: 9 | actions: 10 | patterns: 11 | - "*" 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2013 Ashley DaSilva and Jason Gullifer 2 | 3 | This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. 4 | 5 | This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. 6 | 7 | You should have received a copy of the GNU General Public License along with this program. If not, see 8 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | concurrency: 3 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} 4 | cancel-in-progress: true 5 | on: 6 | workflow_dispatch: 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | lint: 15 | name: Lint 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 19 | - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.1.0 20 | with: 21 | python-version: "3.x" 22 | - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 23 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | skip: [pytest] 3 | autoupdate_schedule: monthly 4 | autoupdate_commit_msg: '[pre-commit.ci] pre-commit autoupdate' 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v4.6.0 8 | hooks: 9 | - id: check-added-large-files 10 | - id: check-case-conflict 11 | - id: check-merge-conflict 12 | - id: check-symlinks 13 | - id: debug-statements 14 | - id: end-of-file-fixer 15 | - id: mixed-line-ending 16 | - id: name-tests-test 17 | args: ["--pytest-test-first"] 18 | - id: requirements-txt-fixer 19 | - id: trailing-whitespace 20 | - id: check-toml 21 | - id: check-yaml 22 | - repo: https://github.com/pappasam/toml-sort 23 | rev: v0.23.1 24 | hooks: 25 | - id: toml-sort-fix 26 | - repo: https://github.com/google/yamlfmt 27 | rev: "v0.12.1" 28 | hooks: 29 | - id: yamlfmt 30 | - repo: https://github.com/codespell-project/codespell 31 | rev: "v2.3.0" 32 | hooks: 33 | - id: codespell 34 | args: [] 35 | - repo: https://github.com/astral-sh/ruff-pre-commit 36 | rev: 'v0.4.10' 37 | hooks: 38 | - id: ruff 39 | - id: ruff-format 40 | - repo: https://github.com/pre-commit/mirrors-mypy 41 | rev: 'v1.10.0' 42 | hooks: 43 | - id: mypy 44 | exclude: tests 45 | fail_fast: false 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | journal2ebook 2 | ============= 3 | 4 | ![A screenshot of journal2ebook in action](./img/screenshot.png) 5 | 6 | Graphical application to convert academic pdfs to epub format for e-readers using 7 | k2pdfopt as a backend. 8 | 9 | The GUI allows you to visualize your PDF and draw appropriate margins that will be 10 | passed to k2pdfopt. The resulting pdf file is output to the folder of your choice. There 11 | is also functionality to save and recall journal profiles that store margin values. 12 | 13 | This program should work cross-platform (Linux, Mac, and Windows) though it has 14 | been most extensively tested with Linux. The Windows executable is usually behind in 15 | features, but is the most recent version that has been tested in Windows. We do not 16 | currently test on Mac. 17 | 18 | The dependencies listed in the following sections must also be met. 19 | 20 | Requirements 21 | ------------ 22 | * Python 3.9 or higher 23 | * `k2pdfopt` to convert pdf to epub 24 | * `tkinter` 25 | 26 | `k2pdfopt` must be in your system's search PATH (or installed to the journal2ebook directory). 27 | 28 | You can check if `tkinter` is properly installed by running `python -m tkinter`. This 29 | command should spawn a smaller window with tkinter's version. If this is not the case, 30 | you might not have tkinter installed and you probably have to run something like 31 | ``` 32 | sudo apt install python3-tk 33 | ``` 34 | 35 | Installation 36 | ------------ 37 | 38 | 1. clone git repository 39 | 2. run `pip install .` from the root directory 40 | 3. run `journal2ebook` to run 41 | 42 | Development 43 | ---------- 44 | 45 | For the development of `journal2ebook`, we use `pre-commit` to lint the project and enforce a consistent code style. 46 | 47 | To get started, install the project in development mode 48 | ```bash 49 | pip install -e ".[develop]" 50 | ``` 51 | and, afterwards, install the pre-commit hooks via 52 | ```bash 53 | pre-commit install 54 | ``` 55 | Now, every time you try to commit a change, the pre-commit hooks run and tell you any issues the linters found. 56 | You can also run them manually via 57 | ```bash 58 | pre-commit run --all 59 | ``` 60 | 61 | Finally, if your IDE supports lsp-servers and is configured properly, you should see all linter errors in your IDE. Happy coding! 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # IPython 78 | profile_default/ 79 | ipython_config.py 80 | 81 | # pyenv 82 | .python-version 83 | 84 | # celery beat schedule file 85 | celerybeat-schedule 86 | 87 | # SageMath parsed files 88 | *.sage.py 89 | 90 | # Environments 91 | .env 92 | .venv 93 | env/ 94 | venv/ 95 | ENV/ 96 | env.bak/ 97 | venv.bak/ 98 | 99 | # Spyder project settings 100 | .spyderproject 101 | .spyproject 102 | 103 | # Rope project settings 104 | .ropeproject 105 | 106 | # mkdocs documentation 107 | /site 108 | 109 | # mypy 110 | .mypy_cache/ 111 | .dmypy.json 112 | dmypy.json 113 | 114 | # Pyre type checker 115 | .pyre/ 116 | 117 | # Latex files 118 | *.acn 119 | *.acr 120 | *.alg 121 | *.aux 122 | *.bbl 123 | *.bcf 124 | *.blg 125 | *.brf 126 | *.dvi 127 | *.fdb_latexmk 128 | *.fls 129 | *.glg 130 | *.glo 131 | *.gls 132 | *.idx 133 | *.ilg 134 | *.ind 135 | *.ist 136 | *.lof 137 | *.log 138 | *.lot 139 | *.nav 140 | *.nlg 141 | *.nlo 142 | *.nls 143 | *.out 144 | *.pdfsync 145 | *.ps 146 | *.run.xml 147 | *.snm 148 | *.synctex.gz 149 | *.toc 150 | 151 | # sublime stuff 152 | *.sublime-project 153 | *.sublime-workspace 154 | 155 | # datasets 156 | *.nc 157 | *.npz 158 | -------------------------------------------------------------------------------- /src/journal2ebook/_config.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import asdict, dataclass 3 | from pathlib import Path 4 | from typing import Any 5 | 6 | from appdirs import user_config_dir 7 | 8 | NAME: str = "journal2ebook" 9 | 10 | CONFIG_FILE: str = "config.ini" 11 | CONFIG_DIR: Path = Path(user_config_dir(NAME)) 12 | CONFIG_PATH: Path = CONFIG_DIR / CONFIG_FILE 13 | 14 | 15 | @dataclass 16 | class Profile: 17 | name: str 18 | skip_first_page: bool = False 19 | many_cols: bool = False 20 | color: bool = False 21 | leftmargin: float = 0.0 22 | rightmargin: float = 1.0 23 | topmargin: float = 0.0 24 | bottommargin: float = 1.0 25 | 26 | def __str__(self) -> str: 27 | return self.name 28 | 29 | 30 | def parse(dct: dict[str, Any]) -> Path | Profile | dict[str, Any]: 31 | if "__path__" in dct: 32 | return Path(dct["path"]) 33 | 34 | if "__dataclass__" in dct: 35 | _ = dct.pop("__dataclass__") 36 | return Profile(**dct) 37 | return dct 38 | 39 | 40 | class JSONEncoder(json.JSONEncoder): 41 | def default(self, obj: Any) -> Any: # noqa: ANN401 42 | if isinstance(obj, Path): 43 | return {"__path__": True, "path": str(obj)} 44 | 45 | if isinstance(obj, Profile): 46 | ret = asdict(obj) 47 | ret["__dataclass__"] = True 48 | return ret 49 | 50 | return super().default(obj) 51 | 52 | 53 | CONFIG_DEFAULT = { 54 | "last_dir": Path("~"), 55 | "last_profile": 0, 56 | "profiles": [Profile("Default")], 57 | "k2pdfopt_path": None, 58 | } 59 | 60 | 61 | class Config: 62 | def __init__(self) -> None: 63 | self._path = CONFIG_PATH 64 | self._config = CONFIG_DEFAULT 65 | 66 | self.load() 67 | 68 | def save(self) -> None: 69 | self._path.parent.mkdir(parents=True, exist_ok=True) 70 | 71 | with self._path.open("w", encoding="utf-8") as cfg: 72 | json.dump(self._config, cfg, indent=4, cls=JSONEncoder) 73 | 74 | def load(self) -> None: 75 | try: 76 | with self._path.open("r", encoding="utf-8") as cfg: 77 | self._config = json.load(cfg, object_hook=parse) 78 | except FileNotFoundError: 79 | return 80 | 81 | def __getitem__(self, name: str) -> Any: # noqa: ANN401 82 | if name in self._config: 83 | return self._config[name] 84 | 85 | return None 86 | 87 | def __setitem__(self, name: str, value: Any) -> None: # noqa: ANN401 88 | self._config[name] = value 89 | self.save() 90 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "setuptools.build_meta" 3 | requires = ["matplotlib", "setuptools"] 4 | 5 | [project] 6 | authors = [ 7 | {name = "Ashley DaSilva"}, 8 | {name = "Constantin Gahr"}, 9 | {name = "Jason Gullifer"} 10 | ] 11 | classifiers = [ 12 | "Programming Language :: Python :: 3.8", 13 | "Programming Language :: Python :: 3.9", 14 | "Programming Language :: Python :: 3.10", 15 | "Programming Language :: Python :: 3.11", 16 | "Programming Language :: Python :: 3.12", 17 | "Operating System :: OS Independent" 18 | ] 19 | dependencies = [ 20 | "appdirs", 21 | "click", 22 | "image", 23 | "pdf2image" 24 | ] 25 | description = "GUI for k2pdfopt" 26 | dynamic = ["version"] 27 | keywords = ["epub", "pdf", "pdf-conversion"] 28 | license = {text = "GNU"} 29 | name = "journal2ebook" 30 | readme = "README.md" 31 | requires-python = ">=3.8" 32 | 33 | [project.gui-scripts] 34 | journal2ebook = "journal2ebook._window:main" 35 | 36 | [project.optional-dependencies] 37 | develop = [ 38 | "pre-commit", 39 | "pylsp-mypy", 40 | "python-lsp-server", 41 | "ruff", 42 | "ruff-lsp" 43 | ] 44 | 45 | [project.urls] 46 | Homepage = "https://github.com/adasilva/journal2ebook" 47 | Issues = "https://github.com/adasilva/journal2ebook/issues" 48 | 49 | [tool.coverage.report] 50 | exclude_lines = ["if TYPE_CHECKING:"] 51 | 52 | [tool.coverage.run] 53 | source = ["src"] 54 | 55 | [tool.isort] 56 | profile = "black" 57 | 58 | [tool.mypy] 59 | check_untyped_defs = true 60 | enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] 61 | exclude = [ 62 | ".cache", 63 | ".git", 64 | ".ipynb_checkpoints", 65 | "__pycache__", 66 | "build", 67 | "dist", 68 | "examples", 69 | "setup*", 70 | "tests" 71 | ] 72 | mypy_path = "src" 73 | no_implicit_optional = true 74 | no_implicit_reexport = true 75 | strict = false 76 | strict_equality = true 77 | warn_redundant_casts = true 78 | warn_return_any = true 79 | warn_unreachable = true 80 | warn_unused_configs = true 81 | 82 | [[tool.mypy.overrides]] 83 | ignore_missing_imports = true 84 | module = [ 85 | "matplotlib.*" 86 | ] 87 | 88 | [tool.pylsp-mypy] 89 | enabled = true 90 | exclude = [ 91 | ".cache", 92 | ".git", 93 | ".ipynb_checkpoints", 94 | "__pycache__", 95 | "build", 96 | "dist", 97 | "examples", 98 | "setup*", 99 | "tests" 100 | ] 101 | live_mode = true 102 | strict = false 103 | 104 | [tool.ruff] 105 | fix = true 106 | src = ["src"] 107 | 108 | [tool.ruff.lint] 109 | fixable = ["I"] 110 | ignore = [ 111 | "ANN101" # missing-type-self 112 | ] 113 | select = [ 114 | "A", 115 | "ANN", 116 | "ARG", 117 | "B", 118 | "BLE", 119 | "C4", 120 | "C90", 121 | "DTZ", 122 | "E", 123 | "EM", 124 | "ERA", 125 | "EXE", 126 | "F", 127 | "FBT", # unclear if good or not 128 | "G", 129 | "I", 130 | "ICN", 131 | "INP", 132 | "ISC", 133 | "N", 134 | "NPY", 135 | "PGH", 136 | "PIE", 137 | "PL", 138 | "PT", 139 | "PTH", 140 | "PYI", 141 | "RET", 142 | "RSE", 143 | "RUF", 144 | "S", 145 | "SIM", 146 | "SLF", 147 | "T10", 148 | "T20", 149 | "TCH", 150 | "TID", 151 | "TRY", 152 | "UP", 153 | # "D", 154 | "W", 155 | "YTT" 156 | ] 157 | 158 | [tool.ruff.lint.flake8-annotations] 159 | suppress-dummy-args = true 160 | 161 | [tool.ruff.lint.per-file-ignores] 162 | "__init__.py" = ["F401", "F403"] 163 | "_window.py" = ["ANN001"] 164 | "tests/*" = [ 165 | "ANN", 166 | "ARG002", # unused-method-argument 167 | "INP", # implicit-namespace-package 168 | "PLR0913", # too-many-arguments 169 | "S101", # assert 170 | "SLF001" # private-member-access 171 | ] 172 | 173 | [tool.ruff.lint.pylint] 174 | max-args = 5 175 | 176 | [tool.setuptools.dynamic] 177 | version = {attr = "journal2ebook._version.__version__"} 178 | 179 | [tool.setuptools.package-data] 180 | journal2ebook = ["py.typed"] 181 | 182 | [tool.setuptools.packages.find] 183 | where = ["src"] 184 | 185 | [tool.tomlsort] 186 | all = true 187 | in_place = true 188 | spaces_before_inline_comment = 2 189 | spaces_indent_inline_array = 4 190 | 191 | [tool.tomlsort.overrides] 192 | "project.classifiers".inline_arrays = false 193 | "tool.ruff.select".inline_arrays = false 194 | -------------------------------------------------------------------------------- /src/journal2ebook/_window.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import enum 3 | import subprocess 4 | import tkinter as tk 5 | import tkinter.filedialog 6 | from pathlib import Path 7 | from tkinter import ttk 8 | from typing import Any, Optional 9 | 10 | import click 11 | import pdf2image 12 | from PIL import ImageTk 13 | 14 | from ._config import Config, Profile 15 | from ._exceptions import NoPdfSelectedError 16 | 17 | PADDING_BETWEEN_SLIDERS = 10 18 | SLIDERLENGTH = 14 19 | WINDOW_TITLE = "journal2ebook" 20 | 21 | 22 | class Position(enum.Enum): 23 | LEFT = enum.auto() 24 | RIGHT = enum.auto() 25 | TOP = enum.auto() 26 | BOTTOM = enum.auto() 27 | 28 | 29 | class Scale(tk.Scale): 30 | def __init__(self, master, *, position: Position) -> None: 31 | self.position = position 32 | 33 | if self.position in (Position.LEFT, Position.RIGHT): 34 | length = master.canvas.winfo_width() 35 | orient: Any = tk.HORIZONTAL 36 | else: 37 | length = master.canvas.winfo_height() 38 | orient = tk.VERTICAL 39 | 40 | if self.position in (Position.LEFT, Position.TOP): 41 | from_ = 0 42 | to = 0.5 - 0.5 * (PADDING_BETWEEN_SLIDERS + SLIDERLENGTH) / length 43 | else: 44 | from_ = 0.5 + 0.5 * (PADDING_BETWEEN_SLIDERS + SLIDERLENGTH) / length 45 | to = 1 46 | 47 | super().__init__( 48 | master, 49 | from_=from_, 50 | to=to, 51 | orient=orient, 52 | resolution=0.01, 53 | sliderlength=SLIDERLENGTH, 54 | length=length / 2.0, 55 | showvalue=False, 56 | command=lambda _: self.draw(), 57 | ) 58 | 59 | self._item_id: Optional[int] = None 60 | 61 | if self.position == Position.LEFT: 62 | self.grid(row=0, column=1, sticky=tk.W) 63 | self.set(0.0) 64 | elif self.position == Position.RIGHT: 65 | self.grid(row=0, column=2, sticky=tk.E) 66 | self.set(1.0) 67 | elif self.position == Position.TOP: 68 | self.grid(row=1, column=0, sticky=tk.N) 69 | self.set(0.0) 70 | else: 71 | self.grid(row=2, column=0, sticky=tk.S) 72 | self.set(1.0) 73 | 74 | @property 75 | def canvas(self): # noqa: ANN202 76 | return self.master.canvas # type: ignore[attr-defined] 77 | 78 | def draw(self) -> None: 79 | if self.position in (Position.LEFT, Position.RIGHT): 80 | x_pos = self.get() * self.canvas.winfo_width() 81 | coords = (x_pos, 0, x_pos, self.canvas.winfo_height()) 82 | else: 83 | y_pos = self.get() * self.canvas.winfo_height() 84 | coords = (0, y_pos, self.canvas.winfo_width(), y_pos) 85 | 86 | if self._item_id is None or self._item_id not in self.canvas.find_all(): 87 | self._item_id = self.canvas.create_line(*coords, tags=("scale")) 88 | else: 89 | self.canvas.coords(self._item_id, *coords) 90 | 91 | 92 | class App(ttk.Frame): 93 | canvas: Any 94 | page: tk.IntVar 95 | _max_pages: int 96 | path: Path 97 | 98 | scale_left: Scale 99 | scale_right: Scale 100 | scale_top: Scale 101 | scale_bottom: Scale 102 | 103 | profiles: Any 104 | 105 | _images: list[Any] 106 | 107 | width: int 108 | height: int 109 | 110 | def __init__(self, master, path: Path) -> None: 111 | super().__init__(master, padding=10) 112 | 113 | self._config = Config() 114 | 115 | self.path = self.require_path(path) 116 | self.load_pdf() 117 | 118 | self.page = tk.IntVar(self, value=1) 119 | self.page.trace_add("write", self.draw_image) 120 | 121 | self.grid() 122 | self.init_menu() 123 | 124 | self.set_width_height() 125 | self.canvas = tk.Canvas(self, width=self.width, height=self.height) 126 | self.canvas.grid( 127 | row=1, column=1, columnspan=2, rowspan=2, sticky=tk.NW, padx=7, pady=7 128 | ) 129 | self.draw_image() 130 | 131 | self.scale_left = Scale(self, position=Position.LEFT) 132 | self.scale_right = Scale(self, position=Position.RIGHT) 133 | self.scale_top = Scale(self, position=Position.TOP) 134 | self.scale_bottom = Scale(self, position=Position.BOTTOM) 135 | 136 | self.init_page_counter() 137 | 138 | self.skip_first_page = tk.BooleanVar(value=False) 139 | self.many_cols = tk.BooleanVar(value=False) 140 | self.color = tk.BooleanVar(value=False) 141 | self.init_extras() 142 | 143 | @property 144 | def num_pages(self) -> int: 145 | return len(self._images) 146 | 147 | def require_path(self, path: Path | None) -> Path: 148 | if path is not None: 149 | return path 150 | 151 | init_dir = self._config["last_dir"] 152 | maybe_path: str | tuple = tk.filedialog.askopenfilename( 153 | parent=self, initialdir=init_dir, filetypes=[("pdf", "*.pdf")] 154 | ) 155 | 156 | if isinstance(maybe_path, tuple): 157 | if hasattr(self, "path"): 158 | return self.path 159 | raise NoPdfSelectedError 160 | 161 | path = Path(maybe_path) 162 | 163 | self._config["last_dir"] = path.parent.absolute() 164 | return path 165 | 166 | def load_pdf(self) -> None: 167 | self._images = pdf2image.convert_from_path(self.path) 168 | 169 | def set_width_height(self, height: int = 600) -> None: 170 | img = self._images[self.page.get() - 1] 171 | aspect = img.size[0] / img.size[1] 172 | 173 | self.height = height 174 | self.width = int(height * aspect) 175 | 176 | def draw_image(self, *_) -> None: 177 | img = self._images[self.page.get() - 1] 178 | img = img.resize((self.width, self.height)) 179 | 180 | self.img = ImageTk.PhotoImage(img) 181 | self.canvas.create_image(self.width / 2.0, self.height / 2.0, image=self.img) 182 | 183 | for _id in self.canvas.find_withtag("scale"): 184 | self.canvas.delete(_id) 185 | self.canvas.update() 186 | 187 | def open_pdf(self) -> None: 188 | self.path = self.require_path(None) 189 | self.load_pdf() 190 | self.set_width_height() 191 | self.page.set(1) 192 | self.draw_image() 193 | 194 | self.scale_left.draw() 195 | self.scale_right.draw() 196 | self.scale_top.draw() 197 | self.scale_bottom.draw() 198 | 199 | def init_menu(self) -> None: 200 | menu = tk.Menu(self) 201 | self.master.config(menu=menu) # type: ignore[attr-defined] 202 | 203 | file_menu = tk.Menu(menu) 204 | menu.add_cascade(label="File", menu=file_menu) 205 | file_menu.add_command(label="Open PDF", command=self.open_pdf) 206 | file_menu.add_command(label="Convert PDF", command=self.convert) 207 | file_menu.add_command(label="Exit", command=self.master.destroy) 208 | 209 | about_menu = tk.Menu(menu) 210 | menu.add_cascade(label="About", menu=about_menu) 211 | about_menu.add_command(label="About journal2ebook") 212 | about_menu.add_command(label="About k2pdfopt") 213 | about_menu.add_command(label="Show config path") 214 | 215 | def init_page_counter(self) -> None: 216 | frame_page = ttk.Frame(self) 217 | frame_page.grid(row=3, column=1, columnspan=2) 218 | 219 | button_decrease = ttk.Button(frame_page) 220 | button_decrease.configure(text="<") 221 | button_decrease.grid(row=0, column=0, sticky=tk.W) 222 | button_decrease.bind("", self._decrease_page) 223 | 224 | button_increase = ttk.Button(frame_page) 225 | button_increase.configure(text=">") 226 | button_increase.grid(row=0, column=2, sticky=tk.E) 227 | button_increase.bind("", self._increase_page) 228 | 229 | entry_page = ttk.Entry(frame_page, textvariable=self.page, width=4) 230 | entry_page.grid(row=0, column=1) 231 | entry_page.bind("", lambda _: self.update()) 232 | 233 | def init_extras(self) -> None: 234 | extras = ttk.Frame(self) 235 | extras.grid(row=1, column=3, rowspan=2, sticky=tk.N + tk.S) 236 | 237 | checkmark_skip_first = tk.Checkbutton( 238 | extras, text="Skip first page", variable=self.skip_first_page 239 | ) 240 | checkmark_skip_first.grid(row=0, column=0, sticky=tk.NW) 241 | 242 | checkmark_extra_cols = tk.Checkbutton( 243 | extras, text="3 or 4 columns", variable=self.many_cols 244 | ) 245 | checkmark_extra_cols.grid(row=1, column=0, sticky=tk.NW) 246 | 247 | checkmark_color = tk.Checkbutton( 248 | extras, text="color output", variable=self.color 249 | ) 250 | checkmark_color.grid(row=2, column=0, sticky=tk.NW) 251 | 252 | # Profiles list box 253 | self.init_profiles(extras) 254 | 255 | # Quit and save buttons on the side 256 | frame_buttons = ttk.Frame(self) 257 | frame_buttons.grid(row=2, column=3, sticky=tk.S) 258 | 259 | button_new_file = ttk.Button(frame_buttons, text="Open file") 260 | button_new_file.grid(row=0, column=0, sticky=tk.E + tk.W) 261 | button_new_file.bind("", lambda _: self.open_pdf()) 262 | button_new_file.bind("", lambda _: self.open_pdf()) 263 | 264 | button_convert = ttk.Button(frame_buttons, text="Convert PDF") 265 | button_convert.grid(row=1, column=0, sticky=tk.E + tk.W) 266 | button_convert.focus_force() 267 | button_convert.bind("", lambda _: self.convert()) 268 | button_convert.bind("", lambda _: self.convert()) 269 | 270 | button_quit = ttk.Button(frame_buttons) 271 | button_quit.configure(text="Quit") 272 | button_quit.grid(row=2, column=0, sticky=tk.E + tk.W) 273 | button_quit.bind("", lambda _: self.master.destroy()) 274 | button_quit.bind("", lambda _: self.master.destroy()) 275 | 276 | def init_profiles(self, master) -> None: 277 | name = tk.StringVar() 278 | profiles = tk.Variable(master, [p.name for p in self._config["profiles"]]) 279 | 280 | self.profiles = tk.Listbox(master, listvariable=profiles) 281 | self.profiles.grid(row=3, column=0, sticky=tk.SW) 282 | self.profiles.bind("<>", lambda _: self.apply_profile(name)) 283 | self.profiles.selection_set(self._config["last_profile"]) 284 | self.profiles.activate(self._config["last_profile"]) 285 | 286 | button_new = ttk.Button(master, text="New profile") 287 | button_new.grid(row=4, column=0, sticky=tk.E + tk.W) 288 | button_new.bind("", lambda _: self.add_new_profile()) 289 | button_new.bind("", lambda _: self.add_new_profile()) 290 | 291 | entry_rename = ttk.Entry(master, textvariable=name, width=4) 292 | entry_rename.grid(row=5, column=0, sticky=tk.E + tk.W) 293 | entry_rename.bind("", lambda _: self.rename_profile(name)) 294 | 295 | button_rename = ttk.Button(master, text="Rename profile") 296 | button_rename.grid(row=6, column=0, sticky=tk.E + tk.W) 297 | button_rename.bind("", lambda _: self.rename_profile(name)) 298 | button_rename.bind("", lambda _: self.rename_profile(name)) 299 | 300 | button_save = ttk.Button(master, text="Save profile") 301 | button_save.grid(row=7, column=0, sticky=tk.E + tk.W) 302 | button_save.bind("", lambda _: self.save_profile()) 303 | button_save.bind("", lambda _: self.save_profile()) 304 | 305 | button_delete = ttk.Button(master, text="Delete profile") 306 | button_delete.grid(row=8, column=0, sticky=tk.E + tk.W) 307 | button_delete.bind("", lambda _: self.delete_profile()) 308 | button_delete.bind("", lambda _: self.delete_profile()) 309 | 310 | self.apply_profile(name) 311 | 312 | def apply_profile(self, name: tk.StringVar) -> None: 313 | selection = self.profiles.curselection() 314 | if len(selection) == 0: 315 | return 316 | 317 | idx = selection[0] 318 | self._config["last_profile"] = idx 319 | 320 | profile = self._config["profiles"][idx] 321 | 322 | self.skip_first_page.set(profile.skip_first_page) 323 | self.many_cols.set(profile.many_cols) 324 | self.color.set(profile.color) 325 | 326 | self.scale_left.set(profile.leftmargin) 327 | self.scale_right.set(profile.rightmargin) 328 | self.scale_top.set(profile.topmargin) 329 | self.scale_bottom.set(profile.bottommargin) 330 | 331 | self.scale_left.draw() 332 | self.scale_right.draw() 333 | self.scale_top.draw() 334 | self.scale_bottom.draw() 335 | 336 | name.set(profile.name) 337 | 338 | def add_new_profile(self) -> None: 339 | profile = Profile("") 340 | self.profiles.insert(tk.END, profile) 341 | self._config["profiles"].append(profile) 342 | 343 | self._config.save() 344 | 345 | def delete_profile(self) -> None: 346 | selection = self.profiles.curselection() 347 | if len(selection) == 0: 348 | return 349 | 350 | idx = selection[0] 351 | del self._config["profiles"][idx] 352 | self.profiles.delete(idx) 353 | 354 | self._config.save() 355 | 356 | def rename_profile(self, name: tk.StringVar) -> None: 357 | _name = name.get() 358 | if _name == "": 359 | return 360 | 361 | selection = self.profiles.curselection() 362 | if len(selection) == 0: 363 | return 364 | 365 | idx = selection[0] 366 | self._config["profiles"][idx].name = _name 367 | self.profiles.delete(idx) 368 | self.profiles.insert(idx, self._config["profiles"][idx]) 369 | 370 | self._config.save() 371 | 372 | def save_profile(self) -> None: 373 | selection = self.profiles.curselection() 374 | if len(selection) == 0: 375 | return 376 | 377 | idx = selection[0] 378 | profile = self._config["profiles"][idx] 379 | 380 | profile.skip_first_page = self.skip_first_page.get() 381 | profile.many_cols = self.many_cols.get() 382 | profile.color = self.color.get() 383 | 384 | profile.leftmargin = self.scale_left.get() 385 | profile.rightmargin = self.scale_right.get() 386 | profile.topmargin = self.scale_top.get() 387 | profile.bottommargin = self.scale_bottom.get() 388 | 389 | self._config.save() 390 | 391 | def _increase_page(self, _) -> None: 392 | self.page.set(min(self.page.get() + 1, self.num_pages)) 393 | 394 | self.scale_left.draw() 395 | self.scale_right.draw() 396 | self.scale_top.draw() 397 | self.scale_bottom.draw() 398 | 399 | def _decrease_page(self, _) -> None: 400 | self.page.set(max(1, self.page.get() - 1)) 401 | 402 | self.scale_left.draw() 403 | self.scale_right.draw() 404 | self.scale_top.draw() 405 | self.scale_bottom.draw() 406 | 407 | def convert(self) -> None: 408 | args = ( 409 | "k2pdfopt", 410 | "-x", 411 | "-c" if self.color.get() else "-c-", 412 | "-p", 413 | f"{self.skip_first_page.get() + 1}-{self.num_pages}", 414 | "-col", 415 | f"{2 + 2 * int(self.many_cols.get())}", 416 | "-ml", 417 | f"{8.5 * self.scale_left.get():.3f}", 418 | "-mr", 419 | f"{8.5 * (1 - self.scale_right.get()):.3f}", 420 | "-mt", 421 | f"{11 * self.scale_top.get():.3f}", 422 | "-mb", 423 | f"{11 * (1 - self.scale_bottom.get()):.3f}", 424 | "-ui-", 425 | "-o", 426 | f"{self.path.with_stem(self.path.stem + '_output')}", 427 | str(self.path), 428 | ) 429 | 430 | with contextlib.suppress(subprocess.CalledProcessError): 431 | subprocess.run(args, check=True) # noqa: S603 432 | 433 | 434 | @click.command() 435 | @click.argument("path", type=click.Path(exists=True, path_type=Path), required=False) 436 | def main(path: Path) -> None: 437 | root = tk.Tk() 438 | root.wm_title("journal2ebook") 439 | try: 440 | myapp = App(root, path) 441 | except NoPdfSelectedError as err: 442 | msg = "No path to a pdf file was provided. Exiting..." 443 | raise SystemExit(msg) from err 444 | myapp.mainloop() 445 | --------------------------------------------------------------------------------