├── .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 | 
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 |
--------------------------------------------------------------------------------