├── .gitignore ├── LICENSE ├── README.md ├── docs ├── Makefile ├── conf.py ├── index.rst └── make.bat ├── mainwindow.ui ├── pypdfbuilder.py ├── pypdfbuilder.sublime-project ├── requirements.txt ├── screenshot.png └── settings.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pdf 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # dotenv 85 | .env 86 | 87 | # virtualenv 88 | .venv 89 | venv/ 90 | ENV/ 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | .spyproject 95 | 96 | # Rope project settings 97 | .ropeproject 98 | 99 | # mkdocs documentation 100 | /site 101 | 102 | # mypy 103 | .mypy_cache/ 104 | *.sublime-workspace 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Thomas Schmitt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyPDF Builder 2 | 3 | A cross-platform utility to join, split, stamp, extract pages, and rotate PDFs written in Python. Yes, Python! 4 | 5 | This project is inspired by Angus Johnson's [PDFTK Builder](http://angusj.com/pdftkb/). Its goal is a GUI that builds on [PyPDF2](https://github.com/mstamy2/PyPDF2) as well as other PDF related libraries and offers a unified and simple experience for end-users. 6 | 7 | This fork added the page extraction module and fixed minor ui issues (and is only tested) on Mac OS. 8 | 9 | ![](screenshot.png) 10 | 11 | 12 | 13 | ## Getting Started 14 | 15 | Grab a copy of `virtualenv` or `virtualenvwrapper` and set up a virtual environment with your favorite Python interpreter (see [Prerequisites](#prerequisites)) to separate the dependencies for this project. Then it's the same old same old: 16 | 17 | ``` 18 | git clone https://github.com/mrgnth/PyPDF-Builder.git 19 | pip install -r requirements.txt 20 | ``` 21 | 22 | These instructions will get you a copy of the project up and running on your local machine for development purposes. 23 | 24 | ### Prerequisites 25 | 26 | PyPDF Builder is built on [Tkinter](https://docs.python.org/3/library/tk.html), [Pygubu](https://github.com/alejandroautalan/pygubu) and [PyPDF2](https://github.com/mstamy2/PyPDF2), a pure-python PDF library. Running `pip freeze` should give you something like this: 27 | 28 | ``` 29 | pygubu==0.9.8.2 30 | PyInstaller==3.3.1 31 | PyPDF2==1.26.0 32 | Sphinx==1.7.2 33 | ``` 34 | 35 | ... and a whole bunch of related dependencies (especially Sphinx is a doozy!). 36 | 37 | Python 3.6 was used in development… I haven't checked for compatibility with lower versions, so your mileage my vary with anything starting 3.5 on downward. 38 | 39 | 40 | ## Deployment 41 | 42 | Distributable application for Windows, Linux and Mac OS using [PyInstaller](https://pyinstaller.readthedocs.io/en/stable/): 43 | 44 | **Linux and Mac OS** 45 | ``` 46 | pyinstaller --onefile --clean --windowed --add-data="mainwindow.ui:." \ 47 | --hidden-import="pygubu.builder.ttkstdwidgets" \ 48 | --hidden-import="pygubu.builder.widgets.dialog" \ 49 | pypdfbuilder.py 50 | ``` 51 | 52 | **Windows** 53 | ``` 54 | pyinstaller --onefile --clean --windowed --add-data="mainwindow.ui;." \ 55 | --hidden-import="pygubu.builder.ttkstdwidgets" \ 56 | --hidden-import="pygubu.builder.widgets.dialog" \ 57 | pypdfbuilder.py 58 | ``` 59 | Subsequent builds can be managed by editing the `.spec` file created by the first build and then simply running `pyinstaller pypdfbuilder.spec` to build the executable. 60 | 61 | Long term: Inclusion in Debian repos for direct installation on end-user systems. 62 | 63 | ## License 64 | 65 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details 66 | 67 | ## Acknowledgments 68 | 69 | * [Matthew Stamy](https://github.com/mstamy2): Creator and current maintainer of the PyPDF2 Python package 70 | * Angus Johnson: Creator of [PDFTK Builder](http://angusj.com/pdftkb/) 71 | 72 | ## To Do 73 | 74 | - [X] Join Tab Functionality 75 | - [X] Split Tab Functionality 76 | - [X] Refactor to avoid code repetition in save, file info, etc methods 77 | - [ ] User Documentation (mostly self-explanatory) 78 | - [ ] Developer Documentation 79 | - [ ] Write tests 80 | - [ ] Error checking user input 81 | - [ ] Error/Exception Handling 82 | - [ ] Failover to system PDF Tools (e.g. Poppler) 83 | - [X] Stamp/Background/Number Tab 84 | - [X] Rotate Pages 85 | - [X] Menus 86 | - [X] Persistent User Settings 87 | - [ ] Logging 88 | - [ ] Error Reporting? 89 | - [ ] Github Project pages with Nikola 90 | - [ ] Package via pyInstaller 91 | - [ ] Distribution via Releases on GitHub 92 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = PyPDFBuilder 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/stable/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | sys.path.insert(0, os.path.abspath('..')) 18 | 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = 'PyPDF Builder' 23 | copyright = '2018, Thomas Schmitt' 24 | author = 'Thomas Schmitt' 25 | 26 | # The short X.Y version 27 | version = '0.1' 28 | # The full version, including alpha/beta/rc tags 29 | release = '0.1' 30 | 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | # If your documentation needs a minimal Sphinx version, state it here. 35 | # 36 | # needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = [ 42 | # 'sphinx.ext.autodoc', 43 | 'sphinx.ext.napoleon', 44 | ] 45 | 46 | # Add any paths that contain templates here, relative to this directory. 47 | templates_path = ['_templates'] 48 | 49 | # The suffix(es) of source filenames. 50 | # You can specify multiple suffix as a list of string: 51 | # 52 | # source_suffix = ['.rst', '.md'] 53 | source_suffix = '.rst' 54 | 55 | # The master toctree document. 56 | master_doc = 'index' 57 | 58 | # The language for content autogenerated by Sphinx. Refer to documentation 59 | # for a list of supported languages. 60 | # 61 | # This is also used if you do content translation via gettext catalogs. 62 | # Usually you set "language" from the command line for these cases. 63 | language = None 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | # This pattern also affects html_static_path and html_extra_path . 68 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 69 | 70 | # The name of the Pygments (syntax highlighting) style to use. 71 | pygments_style = 'sphinx' 72 | 73 | 74 | # -- Options for HTML output ------------------------------------------------- 75 | 76 | # The theme to use for HTML and HTML Help pages. See the documentation for 77 | # a list of builtin themes. 78 | # 79 | html_theme = 'alabaster' 80 | 81 | # Theme options are theme-specific and customize the look and feel of a theme 82 | # further. For a list of options available for each theme, see the 83 | # documentation. 84 | # 85 | # html_theme_options = {} 86 | 87 | # Add any paths that contain custom static files (such as style sheets) here, 88 | # relative to this directory. They are copied after the builtin static files, 89 | # so a file named "default.css" will overwrite the builtin "default.css". 90 | html_static_path = ['_static'] 91 | 92 | # Custom sidebar templates, must be a dictionary that maps document names 93 | # to template names. 94 | # 95 | # The default sidebars (for documents that don't match any pattern) are 96 | # defined by theme itself. Builtin themes are using these templates by 97 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 98 | # 'searchbox.html']``. 99 | # 100 | # html_sidebars = {} 101 | 102 | 103 | # -- Options for HTMLHelp output --------------------------------------------- 104 | 105 | # Output file base name for HTML help builder. 106 | htmlhelp_basename = 'PyPDFBuilderdoc' 107 | 108 | 109 | # -- Options for LaTeX output ------------------------------------------------ 110 | 111 | latex_elements = { 112 | # The paper size ('letterpaper' or 'a4paper'). 113 | # 114 | # 'papersize': 'letterpaper', 115 | 116 | # The font size ('10pt', '11pt' or '12pt'). 117 | # 118 | # 'pointsize': '10pt', 119 | 120 | # Additional stuff for the LaTeX preamble. 121 | # 122 | # 'preamble': '', 123 | 124 | # Latex figure (float) alignment 125 | # 126 | # 'figure_align': 'htbp', 127 | } 128 | 129 | # Grouping the document tree into LaTeX files. List of tuples 130 | # (source start file, target name, title, 131 | # author, documentclass [howto, manual, or own class]). 132 | latex_documents = [ 133 | (master_doc, 'PyPDFBuilder.tex', 'PyPDF Builder Documentation', 134 | 'Thomas Schmitt', 'manual'), 135 | ] 136 | 137 | 138 | # -- Options for manual page output ------------------------------------------ 139 | 140 | # One entry per manual page. List of tuples 141 | # (source start file, name, description, authors, manual section). 142 | man_pages = [ 143 | (master_doc, 'pypdfbuilder', 'PyPDF Builder Documentation', 144 | [author], 1) 145 | ] 146 | 147 | 148 | # -- Options for Texinfo output ---------------------------------------------- 149 | 150 | # Grouping the document tree into Texinfo files. List of tuples 151 | # (source start file, target name, title, author, 152 | # dir menu entry, description, category) 153 | texinfo_documents = [ 154 | (master_doc, 'PyPDFBuilder', 'PyPDF Builder Documentation', 155 | author, 'PyPDFBuilder', 'One line description of project.', 156 | 'Miscellaneous'), 157 | ] 158 | 159 | 160 | # -- Extension configuration ------------------------------------------------- -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. PyPDF Builder documentation master file, created by 2 | sphinx-quickstart on Tue Apr 24 14:28:51 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to PyPDF Builder's documentation! 7 | ========================================= 8 | 9 | .. automodule:: pypdfbuilder 10 | :members: 11 | 12 | .. toctree:: 13 | :maxdepth: 2 14 | :caption: Contents: 15 | 16 | 17 | 18 | Indices and tables 19 | ================== 20 | 21 | * :ref:`genindex` 22 | * :ref:`modindex` 23 | * :ref:`search` 24 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=PyPDFBuilder 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /mainwindow.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 400 5 | none 6 | false 7 | PyPDF Builder 8 | 500 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 400 17 | 500 18 | 19 | 0 20 | True 21 | 0 22 | 23 | 24 | 25 | 360 26 | 500 27 | 28 | 0 29 | True 30 | 0 31 | 32 | 33 | 34 | Join Files 35 | 36 | 37 | 200 38 | 200 39 | 40 | 0 41 | True 42 | 0 43 | 44 | 45 | 46 | TkHeadingFont 47 | left 48 | 0 10 0 5 49 | Source Documents 50 | 51 | 0 52 | 5 53 | 10 54 | True 55 | 0 56 | w 57 | 58 | 59 | 60 | 61 | 62 | 8 63 | extended 64 | 65 | 66 | 0 67 | 5 68 | 10 69 | True 70 | 1 71 | 72 | 73 | 74 | w 75 | w 76 | 0 77 | false 78 | Column_1 79 | true 80 | false 81 | 0 82 | 83 | 84 | 85 | 86 | w 87 | w 88 | 20 89 | true 90 | Filename 91 | false 92 | true 93 | 390 94 | 95 | 96 | 97 | 98 | w 99 | w 100 | 20 101 | true 102 | Pages 103 | false 104 | true 105 | 85 106 | 107 | 108 | 109 | 110 | 111 | 112 | w 113 | right 114 | 0 10 115 | string:current_file_info 116 | 117 | 0 118 | 3 119 | 10 120 | True 121 | 2 122 | w 123 | 124 | 125 | 126 | 127 | 128 | Pages: 129 | 0 130 | 131 | 3 132 | True 133 | 2 134 | e 135 | 136 | 137 | 138 | 139 | 140 | TkDefaultFont 141 | string:page_select_input 142 | 10 143 | 144 | 145 | 4 146 | 10 147 | True 148 | 2 149 | e 150 | 151 | 152 | 153 | 154 | 155 | jointab_add_file 156 | Add… 157 | 158 | 0 159 | 5 160 | True 161 | 3 162 | e 163 | 164 | 165 | 166 | 167 | 168 | Sort 169 | 170 | 1 171 | 5 172 | True 173 | 3 174 | 175 | 176 | 177 | 178 | 179 | jointab_remove 180 | Remove 181 | 182 | 2 183 | 5 184 | True 185 | 3 186 | w 187 | 188 | 189 | 190 | 191 | 192 | jointab_move_up 193 | Move Up 194 | 195 | 3 196 | True 197 | 3 198 | e 199 | 200 | 201 | 202 | 203 | 204 | jointab_move_down 205 | Move Down 206 | 207 | 4 208 | 5 209 | True 210 | 3 211 | w 212 | 213 | 214 | 215 | 216 | 217 | 0 20 218 | 219 | 0 220 | 5 221 | 20 222 | True 223 | 4 224 | 225 | 226 | 227 | jointab_save_as 228 | Save As… 229 | 230 | 0 231 | 10 232 | True 233 | 0 234 | 235 | 236 | 237 | 238 | 239 | quit 240 | Exit 241 | 242 | 1 243 | 10 244 | True 245 | 0 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 10 258 | Split File 259 | 260 | 261 | 262 | 0 263 | True 264 | 0 265 | 266 | 267 | 0 268 | 0 269 | 1 270 | 271 | 272 | 0 273 | 274 | 275 | 0 276 | 277 | 278 | 279 | 280 | 1 281 | 282 | 283 | 284 | 285 | 286 | 200 287 | 200 288 | 289 | 0 290 | True 291 | 0 292 | 293 | 294 | 295 | splittab_open_file 296 | Source PDF File … 297 | 35 298 | 299 | 0 300 | True 301 | 1 302 | 303 | 304 | 305 | 306 | 307 | 0 5 308 | string:split_file_info 309 | 310 | 0 311 | True 312 | 2 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 0 20 321 | 322 | 0 323 | True 324 | 3 325 | 326 | 327 | 328 | splittab_save_as 329 | Save 330 | 331 | 0 332 | 10 333 | True 334 | 0 335 | 336 | 337 | 338 | 339 | 340 | quit 341 | Exit 342 | 343 | 1 344 | 10 345 | True 346 | 0 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | Background/Stamp/Number 359 | 360 | 361 | 200 362 | 200 363 | 364 | 0 365 | True 366 | 0 367 | 368 | 369 | 1 370 | 371 | 372 | 373 | 374 | 1 375 | 376 | 377 | 378 | 379 | 380 | 200 381 | 200 382 | 383 | 0 384 | True 385 | 0 386 | 387 | 388 | 0 389 | 390 | 391 | 392 | 393 | 0 394 | 395 | 396 | 397 | 398 | 399 | bgtab_choose_bg_option 400 | Background 401 | BG 402 | string:bg_command 403 | 404 | 0 405 | 10 406 | True 407 | 0 408 | 409 | 410 | 411 | 412 | 413 | bgtab_choose_stamp_option 414 | Stamp 415 | STAMP 416 | string:bg_command 417 | 418 | 1 419 | 10 420 | True 421 | 0 422 | 423 | 424 | 425 | 426 | 427 | bgtab_choose_number_option 428 | disabled 429 | Number 430 | NUMBER 431 | string:bg_command 432 | 433 | 2 434 | 10 435 | True 436 | 0 437 | 438 | 439 | 440 | 441 | 442 | False 443 | True 444 | Apply background to only the first page 445 | string:bg_options_only_first_button 446 | boolean:bg_only_first_page 447 | 448 | 0 449 | 3 450 | 20 451 | True 452 | 1 453 | 454 | 455 | 456 | 457 | 458 | bgtab_choose_source_file 459 | Source PDF Document … 460 | 25 461 | 462 | 0 463 | 3 464 | True 465 | 2 466 | 467 | 468 | 469 | 470 | 471 | string:source_file_info 472 | 473 | 0 474 | 3 475 | True 476 | 3 477 | 478 | 479 | 480 | 481 | 482 | bgtab_choose_bg_file 483 | Choose Background … 484 | string:bg_options_bg_button 485 | 25 486 | 487 | 0 488 | 3 489 | True 490 | 4 491 | 492 | 493 | 494 | 495 | 496 | string:bg_file_info 497 | 498 | 0 499 | 3 500 | True 501 | 5 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 0 20 510 | 511 | 0 512 | True 513 | 3 514 | 515 | 516 | 517 | bgtab_save_as 518 | Save As … 519 | 520 | 0 521 | 10 522 | True 523 | 0 524 | 525 | 526 | 527 | 528 | 529 | quit 530 | Exit 531 | 532 | 1 533 | 10 534 | True 535 | 0 536 | 537 | 538 | 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | Rotate / Extract Pages 548 | 549 | 550 | 200 551 | 200 552 | 553 | 0 554 | True 555 | 0 556 | 557 | 558 | 1 559 | 560 | 561 | 562 | 563 | 1 564 | 565 | 566 | 567 | 568 | 569 | 200 570 | 200 571 | 572 | 0 573 | True 574 | 0 575 | 576 | 577 | 578 | rotatetab_open_file 579 | Source PDF File … 580 | 35 581 | 582 | 0 583 | True 584 | 1 585 | 586 | 587 | 588 | 589 | 590 | 0 5 591 | string:rotate_file_info 592 | 593 | 0 594 | True 595 | 2 596 | 597 | 598 | 599 | 600 | 601 | 200 602 | 200 603 | 604 | 0 605 | 10 606 | True 607 | 3 608 | 609 | 610 | 611 | Rotate pages from 612 | 613 | 0 614 | True 615 | 1 616 | 617 | 618 | 619 | 620 | 621 | int:rotate_from_page 622 | 3 623 | 624 | 1 625 | 5 626 | True 627 | 1 628 | 629 | 630 | 631 | 632 | 633 | center 634 | to 635 | 0 636 | 2 637 | 638 | 2 639 | True 640 | 1 641 | 642 | 643 | 644 | 645 | 646 | int:rotate_to_page 647 | 3 648 | 649 | 3 650 | 5 651 | True 652 | 1 653 | 654 | 655 | 656 | 657 | 658 | 659 | 660 | 200 661 | 200 662 | 663 | 0 664 | 10 665 | True 666 | 4 667 | 668 | 669 | 670 | No Rotate 671 | NO_ROTATE 672 | string:rotate_amount 673 | 8 674 | 675 | 0 676 | 10 677 | True 678 | 0 679 | 680 | 681 | 682 | 683 | 684 | Left 90° 685 | LEFT 686 | string:rotate_amount 687 | 8 688 | 689 | 1 690 | 10 691 | True 692 | 0 693 | 694 | 695 | 696 | 697 | 698 | Right 90° 699 | RIGHT 700 | string:rotate_amount 701 | 8 702 | 703 | 2 704 | 10 705 | True 706 | 0 707 | 708 | 709 | 710 | 711 | 712 | 180° 713 | ONE_EIGHTY 714 | string:rotate_amount 715 | 8 716 | 717 | 3 718 | 10 719 | True 720 | 0 721 | 722 | 723 | 724 | 725 | 726 | 727 | 728 | 729 | 730 | 0 20 731 | 732 | 0 733 | True 734 | 3 735 | 736 | 737 | 738 | rotatetab_save_as 739 | Save As … 740 | 741 | 0 742 | 10 743 | True 744 | 0 745 | 746 | 747 | 748 | 749 | 750 | quit 751 | Exit 752 | 753 | 1 754 | 10 755 | True 756 | 0 757 | 758 | 759 | 760 | 761 | 762 | 763 | Extract pages 764 | boolean:do_extract_pages 765 | 766 | 2 767 | True 768 | 0 769 | 770 | 771 | 772 | 773 | 774 | 775 | 776 | 777 | 778 | 779 | 780 | 781 | 782 | 783 | 784 | 0 785 | 10 786 | True 787 | 1 788 | sw 789 | 790 | 791 | 792 | string:application_status_text 793 | 794 | 0 795 | True 796 | 4 797 | 798 | 799 | 800 | 801 | 802 | 803 | 804 | 805 | 806 | 807 | 808 | File 809 | 810 | 811 | Alt+F4 812 | quit 813 | false 814 | Exit 815 | 816 | 817 | 818 | 819 | 820 | 821 | View 822 | 823 | 824 | Strg+J 825 | select_tab_join 826 | false 827 | Join Files 828 | 829 | 830 | 831 | 832 | Strg+S 833 | select_tab_split 834 | false 835 | Split Files 836 | 837 | 838 | 839 | 840 | Strg+B 841 | select_tab_bg 842 | false 843 | Background/Stamp/Number 844 | 845 | 846 | 847 | 848 | Strg+R 849 | select_tab_rotate 850 | false 851 | Rotate Pages 852 | 853 | 854 | 855 | 856 | 857 | 858 | 859 | Strg+P 860 | false 861 | Show Document Protection 862 | disabled 863 | 864 | 865 | 866 | 867 | 868 | 869 | 870 | Strg+T 871 | show_settings 872 | false 873 | Settings 874 | 875 | 876 | 877 | 878 | 879 | 880 | Help 881 | 882 | 883 | 884 | 885 | 400x300 886 | 100 887 | true 888 | none 889 | true 890 | Settings 891 | 200 892 | 893 | 894 | 895 | 200 896 | 10 897 | 400 898 | 899 | 0 900 | True 901 | 0 902 | 903 | 904 | 1 905 | 906 | 907 | 908 | 909 | 1 910 | 911 | 912 | 913 | 914 | 915 | 100 916 | System Tools 917 | 180 918 | 919 | 0 920 | True 921 | 0 922 | 923 | 924 | 0 925 | 0 926 | 927 | 928 | 929 | 930 | 1 931 | 932 | 933 | 934 | 935 | 936 | disabled 937 | Use Poppler PDF Tools if available 938 | boolean:settings_use_poppler 939 | 940 | 0 941 | True 942 | 0 943 | 944 | 945 | 946 | 947 | 948 | 949 | 950 | 951 | 952 | -------------------------------------------------------------------------------- /pypdfbuilder.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import os 4 | import sys 5 | import appdirs 6 | import json 7 | from pathlib import Path as plPath 8 | from operator import itemgetter 9 | from settings import * 10 | 11 | from tkinter import filedialog 12 | from pygubu import Builder as pgBuilder 13 | 14 | # if dist fails to start because it's missing these, uncomment these two imports 15 | # import pygubu.builder.ttkstdwidgets 16 | # import pygubu.builder.widgets.dialog 17 | 18 | from PyPDF2 import PdfFileMerger, PdfFileReader, PdfFileWriter 19 | 20 | # check to see if we're running from stand-alone one-file executable: 21 | if hasattr(sys, '_MEIPASS'): 22 | CURRENT_DIR = sys._MEIPASS 23 | else: 24 | CURRENT_DIR = os.path.abspath(os.path.dirname(__file__)) 25 | USER_DIR = str(plPath.home()) 26 | CONFIG_DIR = appdirs.user_config_dir(APPNAME) 27 | DATA_DIR = appdirs.user_data_dir(APPNAME) 28 | 29 | 30 | class SettingsData: 31 | '''Class for managing current user's application settings''' 32 | 33 | def __init__(self): 34 | self.__settings_data_path = os.path.join(CONFIG_DIR, 'data.json') 35 | self.__settings_defaults = { 36 | 'use_poppler_tools': False, 37 | } 38 | self.__settings_data = self.__get_settings_data() 39 | 40 | @property 41 | def use_poppler_tools(self): 42 | '''If set to True, PyPDF Builder will first try to use Poppler Tools where possible 43 | to produce the desired PDFs. 44 | 45 | The getter will first try to return the value stored in the 46 | instance, then try to read it out of the user data file, and if all else fails, 47 | set it to False and return that value. 48 | 49 | The setter will set the according class instance property and save that property to 50 | a settings data file. If no such file exists yet, one will be created. 51 | ''' 52 | return self.__settings_data.get('use_poppler_tools', self.__get_settings_data()['use_poppler_tools']) 53 | 54 | @use_poppler_tools.setter 55 | def use_poppler_tools(self, val): 56 | self.__settings_data['use_poppler_tools'] = val 57 | self.__save_settings_data() 58 | 59 | def __get_settings_data(self): 60 | '''Method to retrieve current user's settings data 61 | 62 | Return: 63 | dict: Dictionary of settings data with keys: 64 | * `use_poppler_tools`: user Poppler PDF tools by default 65 | ''' 66 | try: 67 | with (open(self.__settings_data_path, 'r')) as datafile: 68 | settings_data = json.load(datafile) 69 | # make sure all values are returned. If a key is non-existant, fill it with default value 70 | for key, val in self.__settings_defaults.items(): 71 | if key not in settings_data: 72 | settings_data[key] = val 73 | except FileNotFoundError: 74 | settings_data = self.__settings_defaults 75 | return settings_data 76 | 77 | def __save_settings_data(self): 78 | if not os.path.exists(os.path.dirname(self.__settings_data_path)): 79 | plPath(os.path.dirname(self.__settings_data_path)).mkdir(parents=True, exist_ok=True) 80 | try: 81 | with (open(self.__settings_data_path, 'w')) as datafile: 82 | json.dump(self.__settings_data, datafile) 83 | except FileNotFoundError: 84 | print('Something went horribly wrong while trying to save your current user data.') 85 | 86 | 87 | class UserData: 88 | '''Class for storing current user's application data''' 89 | 90 | def __init__(self): 91 | self.__user_data_path = os.path.join(DATA_DIR, 'data.json') 92 | self.__data_defaults = { 93 | 'filedialog_path': USER_DIR, 94 | 'number_of_processed_files': 0, 95 | } 96 | self.__user_data = self.__get_user_data() 97 | 98 | @property 99 | def filedialog_path(self): 100 | '''The last directory the user visited while opening or saving a file 101 | using a Tk File Dialog. 102 | 103 | The getter will first try to return the value stored in the 104 | instance, then try to read it out of the user data file, and if all else fails, 105 | set it to the user's home directory and return that value. 106 | 107 | The setter will set the according class instance property and save that property to 108 | a user data file. If no such file exists yet, one will be created. 109 | ''' 110 | return self.__user_data.get('filedialog_path', self.__get_user_data()['filedialog_path']) 111 | 112 | @filedialog_path.setter 113 | def filedialog_path(self, val): 114 | self.__user_data['filedialog_path'] = val 115 | self.__save_user_data() 116 | 117 | @property 118 | def number_of_processed_files(self): 119 | '''Simple counter of PDF produced with PyPDF Builder 120 | 121 | The getter will first try to return the value stored in the state of the 122 | instance, then try to read it out of the user data file, and if all else fails, 123 | set it to 0 and return that value. 124 | 125 | The setter will set the according class instance property and save that property to 126 | a user data file. If no such file exists yet, one will be created. 127 | ''' 128 | return self.__user_data.get('number_of_processed_files', self.__get_user_data()['number_of_processed_files']) 129 | 130 | @number_of_processed_files.setter 131 | def number_of_processed_files(self, val): 132 | self.__user_data['number_of_processed_files'] = val 133 | self.__save_user_data() 134 | 135 | def __get_user_data(self): 136 | '''Method to retrieve current user's data 137 | 138 | Return: 139 | dict: Dictionary of user data with keys: 140 | * `filedialog_path`: last accessed file path 141 | * `number_of_processed_files`: number of processed files 142 | ''' 143 | try: 144 | with (open(self.__user_data_path, 'r')) as datafile: 145 | user_data = json.load(datafile) 146 | # make sure all values are returned. If a key is non-existant, fill it with default value 147 | for key, val in self.__data_defaults.items(): 148 | if key not in user_data: 149 | user_data[key] = val 150 | except FileNotFoundError: 151 | user_data = self.__data_defaults 152 | return user_data 153 | 154 | def __save_user_data(self): 155 | if not os.path.exists(os.path.dirname(self.__user_data_path)): 156 | plPath(os.path.dirname(self.__user_data_path)).mkdir(parents=True, exist_ok=True) 157 | try: 158 | with (open(self.__user_data_path, 'w')) as datafile: 159 | json.dump(self.__user_data, datafile) 160 | except FileNotFoundError: 161 | print('Something went horribly wrong while trying to save your current user data.') 162 | 163 | 164 | class PDFInfo: 165 | '''File info class for PDF files. 166 | 167 | Instances of this class show information about PDF files that are being edited in 168 | PyPDF Builder. 169 | 170 | Args: 171 | filepath (str): Path to PDF File 172 | ''' 173 | 174 | def __init__(self, filepath): 175 | self.__filepath = filepath 176 | 177 | @property 178 | def pages(self): 179 | '''int: Number of pages contained in PDF file''' 180 | with open(self.__filepath, 'rb') as in_pdf: 181 | pdf_handler = PdfFileReader(in_pdf) 182 | return pdf_handler.getNumPages() 183 | 184 | def concat_filename(self, max_length=35): 185 | '''Concatenate a filename to a certain length. 186 | 187 | Args: 188 | max_length (int): Maximum length of concatenated string (default: 35) 189 | 190 | Returns: 191 | str: Filename of PDFInfo-object concatenated to max length of `max_length` 192 | 193 | ''' 194 | basename = os.path.basename(self.__filepath) 195 | concat_filename = f'{basename[0:max_length]}' 196 | if len(basename) > max_length: 197 | concat_filename += '…' 198 | return concat_filename 199 | 200 | def pdf_info_string(self, concat_length=35): 201 | '''Fetch a standard info-string about the PDFInfo-object. 202 | 203 | Args: 204 | concat_length (int): Maximum length of concatenated filename string (default: 35) 205 | 206 | Returns: 207 | str: Information in the format `Filename (pages)` of PDFInfo-object 208 | 209 | ''' 210 | concat_filename = self.concat_filename(max_length=concat_length) 211 | return f'{concat_filename} ({self.pages} pages)' 212 | 213 | 214 | class BgTabManager: 215 | def __init__(self, parent=None): 216 | self.parent = parent 217 | self.__source_filepath = None 218 | self.__bg_filepath = None 219 | self.__source_file_info = None 220 | self.__bg_file_info = None 221 | self.__bg_pdf_pages = None 222 | self.__source_file_info_widget = self.parent.builder.get_variable('source_file_info') 223 | self.__bg_file_info_widget = self.parent.builder.get_variable('bg_file_info') 224 | self.__bg_command = self.parent.builder.get_variable('bg_command') 225 | self.__bg_only_first_page = self.parent.builder.get_variable('bg_only_first_page') 226 | self.__bg_button_label = self.parent.builder.get_variable('bg_options_bg_button') 227 | self.__only_first_button_label = self.parent.builder.get_variable('bg_options_only_first_button') 228 | self.__bg_command.set('BG') 229 | 230 | @property 231 | def parent(self): 232 | return self.__parent 233 | 234 | @parent.setter 235 | def parent(self, val): 236 | self.__parent = val 237 | 238 | def choose_source_file(self): 239 | choose_source_file = self.parent.get_file_dialog( 240 | func=filedialog.askopenfilename, widget_title='Choose Source PDF …') 241 | if choose_source_file: 242 | self.__source_filepath = choose_source_file 243 | self.__source_file_info = PDFInfo(self.__source_filepath) 244 | self.__show_source_file_info() 245 | 246 | def choose_bg_file(self): 247 | choose_bg_file = self.parent.get_file_dialog( 248 | func=filedialog.askopenfilename, widget_title='Choose Background PDF …') 249 | if choose_bg_file: 250 | self.__bg_filepath = choose_bg_file 251 | self.__bg_file_info = PDFInfo(self.__bg_filepath) 252 | self.__show_bg_file_info() 253 | 254 | def __show_source_file_info(self): 255 | self.__source_file_info_widget.set(self.__source_file_info.pdf_info_string(concat_length=80)) 256 | 257 | def __show_bg_file_info(self): 258 | self.__bg_file_info_widget.set(self.__bg_file_info.pdf_info_string(concat_length=80)) 259 | 260 | def choose_stamp_option(self): 261 | self.__only_first_button_label.set('Apply stamp to only the first page') 262 | self.__bg_button_label.set('Choose Stamp …') 263 | 264 | def choose_bg_option(self): 265 | self.__only_first_button_label.set('Apply background to only the first page') 266 | self.__bg_button_label.set('Choose Background …') 267 | 268 | def save_as(self): 269 | save_filepath = self.parent.get_file_dialog(func=filedialog.asksaveasfilename, widget_title='Save New PDF to …') 270 | if self.__source_filepath and self.__bg_filepath: 271 | out_pdf = PdfFileWriter() 272 | command = self.__bg_command.get() 273 | with open(self.__source_filepath, "rb") as source_pdf_stream, \ 274 | open(self.__bg_filepath, "rb") as bg_pdf_stream: 275 | for p in range(self.__source_file_info.pages): 276 | # new PdfFileReader instances needed for every page merged. See here: 277 | # https://github.com/mstamy2/PyPDF2/issues/100#issuecomment-43145634 278 | source_pdf = PdfFileReader(source_pdf_stream) 279 | bg_pdf = PdfFileReader(bg_pdf_stream) 280 | if not self.__bg_only_first_page.get() or (self.__bg_only_first_page.get() and p < 1): 281 | if command == 'STAMP': 282 | top_page = bg_pdf.getPage(0) 283 | bottom_page = source_pdf.getPage(p) 284 | elif command == 'BG': 285 | top_page = source_pdf.getPage(p) 286 | bottom_page = bg_pdf.getPage(0) 287 | bottom_page.mergePage(top_page) 288 | else: 289 | bottom_page = source_pdf.getPage(p) 290 | out_pdf.addPage(bottom_page) 291 | with open(save_filepath, "wb") as out_pdf_stream: 292 | out_pdf.write(out_pdf_stream) 293 | self.parent.save_success(status_text=BG_FILE_SUCCESS.format(os.path.basename(save_filepath))) 294 | 295 | 296 | class SplitTabManager: 297 | '''Manager class for the Split Tab 298 | 299 | An instance of this class manages all aspects of the Split Tab in the calling `PyPDFBuilderApplication` instance 300 | 301 | Args: 302 | parent (PyPDFBuilderApplication): Application that created the instance and that contains the Split Tab. 303 | ''' 304 | 305 | def __init__(self, parent=None): 306 | self.parent = parent 307 | self.__split_filepath = None 308 | self.__split_file_info = None 309 | self.__split_file_info_widget = self.parent.builder.get_variable('split_file_info') 310 | 311 | @property 312 | def parent(self): 313 | '''PyPDFBuilderApplication: Application that created the instance and that contains the Split Tab.''' 314 | return self.__parent 315 | 316 | @parent.setter 317 | def parent(self, val): 318 | self.__parent = val 319 | 320 | def open_file(self): 321 | choose_split_file = self.parent.get_file_dialog( 322 | func=filedialog.askopenfilename, widget_title='Choose PDF to Split…') 323 | if choose_split_file: 324 | self.__split_filepath = choose_split_file 325 | self.__split_file_info = PDFInfo(self.__split_filepath) 326 | self.__show_file_info() 327 | 328 | def __show_file_info(self): 329 | self.__split_file_info_widget.set(self.__split_file_info.pdf_info_string()) 330 | 331 | def save_as(self): 332 | if self.__split_filepath: 333 | basepath = os.path.splitext(self.__split_filepath)[0] 334 | # in spite of discussion here https://stackoverflow.com/a/2189814 335 | # we'll just go the lazy way to count the number of needed digits: 336 | num_length = len(str(abs(self.__split_file_info.pages))) 337 | in_pdf = PdfFileReader(open(self.__split_filepath, "rb")) 338 | for p in range(self.__split_file_info.pages): 339 | output_path = f"{basepath}_{str(p+1).rjust(num_length, '0')}.pdf" 340 | out_pdf = PdfFileWriter() 341 | out_pdf.addPage(in_pdf.getPage(p)) 342 | with open(output_path, "wb") as out_pdf_stream: 343 | out_pdf.write(out_pdf_stream) 344 | self.parent.save_success(status_text=SPLIT_FILE_SUCCESS.format(os.path.dirname(self.__split_filepath))) 345 | 346 | 347 | class RotateTabManager: 348 | def __init__(self, parent=None): 349 | self.parent = parent 350 | self.__rotate_filepath = None 351 | self.__rotate_file_info = None 352 | self.__rotate_file_info_widget = self.parent.builder.get_variable('rotate_file_info') 353 | self.__rotate_from_page_widget = self.parent.builder.get_variable('rotate_from_page') 354 | self.__rotate_to_page_widget = self.parent.builder.get_variable('rotate_to_page') 355 | self.__rotate_amount_widget = self.parent.builder.get_variable('rotate_amount') 356 | self.__do_page_extract_widget = self.parent.builder.get_variable('do_extract_pages') 357 | # Set default values. No idea how to avoid this using only the UI file, so I'm 358 | # breaking the MVC principle here. 359 | self.__rotate_amount_widget.set('NO_ROTATE') 360 | self.__rotate_from_page_widget.set('') 361 | self.__rotate_to_page_widget.set('') 362 | self.__do_page_extract_widget.set(True) 363 | 364 | @property 365 | def parent(self): 366 | return self.__parent 367 | 368 | @parent.setter 369 | def parent(self, val): 370 | self.__parent = val 371 | 372 | def open_file(self): 373 | chose_rotate_file = self.parent.get_file_dialog( 374 | func=filedialog.askopenfilename, widget_title='Choose PDF to Rotate…') 375 | if chose_rotate_file: 376 | self.__rotate_filepath = chose_rotate_file 377 | self.__rotate_file_info = PDFInfo(self.__rotate_filepath) 378 | self.__show_file_info() 379 | self.__show_rotate_pages() 380 | 381 | def __show_rotate_pages(self): 382 | self.__rotate_from_page_widget.set(1) 383 | self.__rotate_to_page_widget.set(self.__rotate_file_info.pages) 384 | 385 | def __show_file_info(self): 386 | self.__rotate_file_info_widget.set(self.__rotate_file_info.pdf_info_string()) 387 | 388 | def save_as(self): 389 | page_range = (self.__rotate_from_page_widget.get()-1, self.__rotate_to_page_widget.get()) 390 | save_filepath = self.parent.get_file_dialog(func=filedialog.asksaveasfilename, widget_title='Save New PDF to…') 391 | if self.__rotate_filepath: 392 | in_pdf = PdfFileReader(open(self.__rotate_filepath, "rb")) 393 | out_pdf = PdfFileWriter() 394 | for p in range(self.__rotate_file_info.pages): 395 | if p in range(*page_range): 396 | if ROTATE_DEGREES[self.__rotate_amount_widget.get()] != 0: 397 | out_pdf.addPage(in_pdf.getPage(p).rotateClockwise( 398 | ROTATE_DEGREES[self.__rotate_amount_widget.get()])) 399 | else: 400 | out_pdf.addPage(in_pdf.getPage(p)) 401 | elif not self.__do_page_extract_widget.get(): 402 | out_pdf.addPage(in_pdf.getPage(p)) 403 | with open(save_filepath, "wb") as out_pdf_stream: 404 | out_pdf.write(out_pdf_stream) 405 | self.parent.save_success(status_text=ROTATE_FILE_SUCCESS.format(os.path.basename(save_filepath))) 406 | 407 | 408 | class JoinTabManager: 409 | def __init__(self, parent=None): 410 | self.parent = parent 411 | self.__current_file_info = None 412 | self.__files_tree_widget = self.parent.builder.get_object('JoinFilesList') 413 | self.__files_tree_widget['displaycolumns'] = ('FileNameColumn', 'PageSelectColumn') 414 | self.__current_file_info_widget = self.parent.builder.get_variable('current_file_info') 415 | self.__page_select_input_widget = self.parent.builder.get_variable('page_select_input') 416 | self.__selected_files = [] 417 | 418 | @property 419 | def parent(self): 420 | return self.__parent 421 | 422 | @parent.setter 423 | def parent(self, val): 424 | self.__parent = val 425 | 426 | def on_file_select(self, event): 427 | self.__selected_files = self.__files_tree_widget.selection() 428 | self.__current_file_info = PDFInfo( 429 | self.__files_tree_widget.item(self.__selected_files[0], 'values')[PDF_FILEPATH]) 430 | self.__show_file_info() 431 | self.__show_selected_pages() 432 | 433 | def enter_page_selection(self, event): 434 | ''' 435 | This medthod is called when the page selection input field loses focus 436 | i.e. when input is completed 437 | ''' 438 | for f in self.__selected_files: 439 | file_data = self.__files_tree_widget.item(f, 'values') 440 | page_select = self.__page_select_input_widget.get() 441 | new_tuple = (file_data[PDF_FILENAME], page_select, file_data[PDF_FILEPATH], file_data[PDF_PAGES]) 442 | self.__files_tree_widget.item(f, values=new_tuple) 443 | 444 | def __show_file_info(self): 445 | self.__current_file_info_widget.set(self.__current_file_info.pdf_info_string(concat_length=25)) 446 | 447 | def __show_selected_pages(self): 448 | file_data = self.__files_tree_widget.item(self.__selected_files[0], 'values') 449 | self.__page_select_input_widget.set(file_data[PDF_PAGESELECT]) 450 | 451 | def __get_join_files(self): 452 | return [self.__files_tree_widget.item(i)['values'] for i in self.__files_tree_widget.get_children()] 453 | 454 | def __parse_page_select(self, page_select): 455 | ''' 456 | As this method deals with raw user input, there will have to be a whole lot of error checking 457 | built into this function at a later time. Really don't look forward to this… at all. 458 | ''' 459 | for page_range in page_select.replace(' ', '').split(','): 460 | if '-' in page_range: 461 | range_list = page_range.split('-') 462 | yield tuple(sorted((int(range_list[0])-1, int(range_list[1])))) 463 | else: 464 | yield tuple(sorted((int(page_range)-1, int(page_range)))) 465 | 466 | def add_file(self): 467 | add_filepaths = self.parent.get_file_dialog( 468 | func=filedialog.askopenfilenames, 469 | widget_title='Choose PDFs to Add…' 470 | ) 471 | if add_filepaths: 472 | for filepath in list(add_filepaths): 473 | filename = os.path.basename(filepath) 474 | file_info = PDFInfo(filepath) 475 | file_data = (filename, '', filepath, file_info.pages) 476 | self.__files_tree_widget.insert('', 'end', values=file_data) 477 | 478 | def save_as(self): 479 | if len(self.__get_join_files()) > 0: 480 | save_filepath = self.parent.get_file_dialog( 481 | func=filedialog.asksaveasfilename, widget_title='Save Joined PDF to…') 482 | if save_filepath: 483 | merger = PdfFileMerger() 484 | for f in self.__get_join_files(): 485 | if not f[PDF_PAGESELECT]: 486 | merger.append(fileobj=open(f[PDF_FILEPATH], 'rb')) 487 | else: 488 | for page_range in self.__parse_page_select(str(f[PDF_PAGESELECT])): 489 | merger.append(fileobj=open(f[PDF_FILEPATH], 'rb'), pages=page_range) 490 | with open(save_filepath, 'wb') as out_pdf: 491 | merger.write(out_pdf) 492 | self.parent.save_success(status_text=JOIN_FILE_SUCCESS.format(os.path.basename(save_filepath))) 493 | 494 | def move_up(self): 495 | selected_files = self.__selected_files 496 | first_idx = self.__files_tree_widget.index(selected_files[0]) 497 | parent = self.__files_tree_widget.parent(selected_files[0]) 498 | if first_idx > 0: 499 | for f in selected_files: 500 | swap_item = self.__files_tree_widget.prev(f) 501 | new_idx = self.__files_tree_widget.index(swap_item) 502 | self.__files_tree_widget.move(f, parent, new_idx) 503 | 504 | def move_down(self): 505 | selected_files = list(reversed(self.__selected_files)) 506 | last_idx = self.__files_tree_widget.index(selected_files[0]) 507 | parent = self.__files_tree_widget.parent(selected_files[0]) 508 | last_idx_in_widget = self.__files_tree_widget.index(self.__files_tree_widget.get_children()[-1]) 509 | if last_idx < last_idx_in_widget: 510 | for f in selected_files: 511 | swap_item = self.__files_tree_widget.next(f) 512 | own_idx = self.__files_tree_widget.index(f) 513 | new_idx = self.__files_tree_widget.index(swap_item) 514 | self.__files_tree_widget.move(f, parent, new_idx) 515 | 516 | def remove_file(self): 517 | for f in self.__selected_files: 518 | self.__files_tree_widget.detach(f) 519 | 520 | 521 | class PyPDFBuilderApplication: 522 | '''Main application class. Handles setup and running of all application parts.''' 523 | 524 | def __init__(self): 525 | self.builder = pgBuilder() 526 | self.builder.add_from_file(os.path.join(CURRENT_DIR, 'mainwindow.ui')) 527 | 528 | self.__mainwindow = self.builder.get_object('MainWindow') 529 | self.__settings_dialog = self.builder.get_object('SettingsDialog', self.__mainwindow) 530 | self.__notebook = self.builder.get_object('AppNotebook') 531 | self.__tabs = { 532 | 'join': self.builder.get_object('JoinFrame'), 533 | 'split': self.builder.get_object('SplitFrame'), 534 | 'bg': self.builder.get_object('BgFrame'), 535 | 'rotate': self.builder.get_object('RotateFrame'), 536 | } 537 | self.__mainmenu = self.builder.get_object('MainMenu') 538 | self.__mainwindow.config(menu=self.__mainmenu) 539 | self.__status_text_variable = self.builder.get_variable('application_status_text') 540 | self.__settings_use_poppler_variable = self.builder.get_variable('settings_use_poppler') 541 | self.status_text = None 542 | self.builder.connect_callbacks(self) 543 | 544 | self.user_data = UserData() 545 | self.settings_data = SettingsData() 546 | 547 | self.__jointab = JoinTabManager(self) 548 | self.__splittab = SplitTabManager(self) 549 | self.__bgtab = BgTabManager(self) 550 | self.__rotatetab = RotateTabManager(self) 551 | 552 | self.status_text = DEFAULT_STATUS 553 | 554 | @property 555 | def status_text(self): 556 | return self.__status_text_variable.get() 557 | 558 | @status_text.setter 559 | def status_text(self, val): 560 | self.__status_text_variable.set(val) 561 | 562 | # boy oh boy if there's anyway to do these callsbacks more elegantly, please let me gain that knowledge! 563 | def select_tab_join(self, *args, **kwargs): 564 | '''Gets called when menu item "View > Join Files" is selected. 565 | Pops appropriate tab into view.''' 566 | self.__notebook.select(self.__tabs['join']) 567 | 568 | def select_tab_split(self, *args, **kwargs): 569 | '''Gets called when menu item "View > Split File" is selected. 570 | Pops appropriate tab into view.''' 571 | self.__notebook.select(self.__tabs['split']) 572 | 573 | def select_tab_bg(self, *args, **kwargs): 574 | '''Gets called when menu item "View > Background/Stamp/Number" is selected. 575 | Pops appropriate tab into view.''' 576 | self.__notebook.select(self.__tabs['bg']) 577 | 578 | def select_tab_rotate(self, *args, **kwargs): 579 | '''Gets called when menu item "View > Rotate Pages" is selected. 580 | Pops appropriate tab into view.''' 581 | self.__notebook.select(self.__tabs['rotate']) 582 | 583 | def jointab_add_file(self): 584 | self.__jointab.add_file() 585 | 586 | def jointab_on_file_select(self, event): 587 | self.__jointab.on_file_select(event) 588 | 589 | def jointab_enter_page_selection(self, event): 590 | self.__jointab.enter_page_selection(event) 591 | 592 | def jointab_save_as(self): 593 | self.__jointab.save_as() 594 | 595 | def jointab_move_up(self): 596 | self.__jointab.move_up() 597 | 598 | def jointab_move_down(self): 599 | self.__jointab.move_down() 600 | 601 | def jointab_remove(self): 602 | self.__jointab.remove_file() 603 | 604 | def splittab_open_file(self): 605 | self.__splittab.open_file() 606 | 607 | def splittab_save_as(self): 608 | self.__splittab.save_as() 609 | 610 | def bgtab_choose_bg_option(self): 611 | self.__bgtab.choose_bg_option() 612 | 613 | def bgtab_choose_stamp_option(self): 614 | self.__bgtab.choose_stamp_option() 615 | 616 | def bgtab_choose_number_option(self): 617 | ''' 618 | Numbering pages is currently not supported by PyPDF2 so this option will remain 619 | disabled for now 620 | ''' 621 | pass 622 | 623 | def bgtab_choose_source_file(self): 624 | self.__bgtab.choose_source_file() 625 | 626 | def bgtab_choose_bg_file(self): 627 | self.__bgtab.choose_bg_file() 628 | 629 | def bgtab_save_as(self): 630 | self.__bgtab.save_as() 631 | 632 | def rotatetab_open_file(self): 633 | self.__rotatetab.open_file() 634 | 635 | def rotatetab_save_as(self): 636 | self.__rotatetab.save_as() 637 | 638 | def save_success(self, status_text=DEFAULT_STATUS): 639 | '''Gets called when a PDF file was processed successfully. Currently only 640 | increases the `number_of_processed_files`-counter by 1 641 | ''' 642 | self.user_data.number_of_processed_files += 1 643 | self.status_text = status_text 644 | 645 | def show_settings(self, *args, **kwargs): 646 | '''Shows the settings dialog. The close event is handled by `self.close_settings()` 647 | and all the settings management is handled there. Args and kwargs are included in 648 | method definition in case it is triggered by the keyboard shortcut, in which 649 | case `event` gets passed into the call.''' 650 | self.__settings_dialog.run() 651 | self.__settings_use_poppler_variable.set(self.settings_data.use_poppler_tools) 652 | 653 | def close_settings(self, *args, **kwargs): 654 | self.settings_data.use_poppler_tools = self.__settings_use_poppler_variable.get() 655 | self.__settings_dialog.close() 656 | 657 | def cancel_settings(self, *args, **kwargs): 658 | pass 659 | 660 | def get_file_dialog(self, func, widget_title='Choose File(s) …'): 661 | f = func( 662 | initialdir=self.user_data.filedialog_path, 663 | title=widget_title, 664 | filetypes=(("PDF File", "*.pdf"), ("All Files", "*.*")) 665 | ) 666 | if f: 667 | if type(f) == list or type(f) == tuple: 668 | self.user_data.filedialog_path = os.path.dirname(f[-1]) 669 | elif type(f) == str: 670 | self.user_data.filedialog_path = os.path.dirname(f) 671 | return f 672 | 673 | def quit(self, event=None): 674 | self.__mainwindow.quit() 675 | 676 | def run(self): 677 | self.__mainwindow.mainloop() 678 | 679 | 680 | if __name__ == '__main__': 681 | app = PyPDFBuilderApplication() 682 | app.run() 683 | -------------------------------------------------------------------------------- /pypdfbuilder.sublime-project: -------------------------------------------------------------------------------- 1 | { 2 | "folders": 3 | [ 4 | { 5 | "path": "." 6 | } 7 | ], 8 | "virtualenv": "/home/thomas/.virtualenvs/pypdfbuilder" 9 | } 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alabaster==0.7.10 2 | altgraph==0.15 3 | appdirs==1.4.3 4 | Babel==2.9.1 5 | chardet==3.0.4 6 | docutils==0.14 7 | future==0.16.0 8 | idna==2.6 9 | imagesize==1.0.0 10 | Jinja2==2.11.3 11 | macholib==1.9 12 | MarkupSafe==1.1.1 13 | packaging==17.1 14 | pefile==2017.11.5 15 | Pygments==2.7.4 16 | pygubu==0.9.8.2 17 | PyInstaller==4.2 18 | pyparsing==2.2.0 19 | PyPDF2==1.27.5 20 | pytz==2018.4 21 | requests==2.31.0 22 | six==1.11.0 23 | snowballstemmer==1.2.1 24 | Sphinx==1.7.2 25 | sphinxcontrib-websupport==1.0.1 26 | urllib3==1.26.5 27 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/py-pdf/PyPDF-Builder/5dce41b09a1b9c43bfb6f102876a4915a7f42afb/screenshot.png -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | APPNAME = 'pypdfbuilder' 2 | APPVERSION = '0.9.1' 3 | 4 | # Constants for indexing into the values stored in the TreeView (e.g. in the Join View) 5 | 6 | PDF_FILENAME = 0 7 | PDF_PAGESELECT = 1 8 | PDF_FILEPATH = 2 9 | PDF_PAGES = 3 10 | 11 | ROTATE_DEGREES = {'LEFT': 270, 'RIGHT': 90, 'ONE_EIGHTY': 180, 'NO_ROTATE': 0} 12 | 13 | 14 | SPLIT_FILE_SUCCESS = 'Files saved successfully to {}!' 15 | JOIN_FILE_SUCCESS = 'Files joined successfully to {}!' 16 | ROTATE_FILE_SUCCESS = 'Pages in {} rotated successfully!' 17 | BG_FILE_SUCCESS = 'File saved successfully to {}!' 18 | DEFAULT_STATUS = F'PyPDF Builder v{APPVERSION}' 19 | --------------------------------------------------------------------------------