├── src ├── __init__.py ├── __version__.py ├── py.typed ├── models │ ├── comic_handler_single_page.py │ ├── comic_loader_factory.py │ ├── comic.py │ ├── page.py │ ├── utils.py │ ├── reading_list_model.py │ ├── constants.py │ ├── comic_handler.py │ ├── comic_loader.py │ ├── comic_loader_pdf.py │ ├── comic_loader_rar.py │ ├── comic_loader_zip.py │ ├── comic_loader_tar.py │ └── main_model.py ├── app.py ├── widgets │ └── qscroll_area_viewer.py └── controllers │ └── main_controller.py ├── tests ├── __init__.py ├── test_comic_loader.py ├── test_main_model.py ├── test_utility.py ├── test_comic_page_handler_single_page.py ├── test_page.py ├── test_main_window_view.py ├── test_comic.py ├── test_comic_loader_factory.py ├── test_comic_page_handler.py ├── test_comic_loader_rar.py ├── test_comic_loader_zip.py └── test_comic_loader_tar.py ├── package └── usr │ ├── bin │ └── .gitkeep │ └── share │ ├── applications │ └── pynocchio.desktop │ └── icons │ └── hicolor │ └── scalable │ └── apps │ └── pynocchio.svg ├── run.py ├── resources ├── logo.xcf ├── i18n │ ├── en_US.qm │ ├── es_ES.qm │ └── pt_BR.qm ├── icons │ ├── dark │ │ ├── keyboard_arrow_left.svg │ │ ├── keyboard_arrow_right.svg │ │ ├── first_page.svg │ │ ├── last_page.svg │ │ ├── close.svg │ │ ├── view_real_size.svg │ │ ├── fullscreen.svg │ │ ├── forward.svg │ │ ├── reply_all.svg │ │ ├── file_open.svg │ │ ├── fit_page.svg │ │ ├── fit_page_height.svg │ │ ├── fit_page_width.svg │ │ ├── two_pager.svg │ │ ├── rotate_left.svg │ │ ├── rotate_right.svg │ │ ├── info.svg │ │ └── bug_report.svg │ └── light │ │ ├── keyboard_arrow_left.svg │ │ ├── keyboard_arrow_right.svg │ │ ├── first_page.svg │ │ ├── last_page.svg │ │ ├── close.svg │ │ ├── view_real_size.svg │ │ ├── fullscreen.svg │ │ ├── forward.svg │ │ ├── reply_all.svg │ │ ├── file_open.svg │ │ ├── fit_page.svg │ │ ├── fit_page_height.svg │ │ ├── fit_page_width.svg │ │ ├── two_pager.svg │ │ ├── rotate_left.svg │ │ ├── rotate_right.svg │ │ ├── info.svg │ │ └── bug_report.svg ├── resources.qrc └── logo.svg ├── .github ├── FUNDING.yml ├── screenshot.png ├── ISSUE_TEMPLATE │ ├── custom.md │ ├── bug_report.md │ └── feature_request.md ├── workflows │ ├── lint.yml │ ├── coverage.yml │ └── test.yml └── PULL_REQUEST_TEMPLATE.md ├── docs ├── img │ ├── screenshot.png │ └── logo.svg └── index.html ├── .fpm ├── CHANGELOG ├── .gitignore ├── .pre-commit-config.yaml ├── README.md ├── Makefile ├── pyproject.toml ├── CONTRIBUTING └── i18n ├── en_US.ts ├── es_ES.ts └── pt_BR.ts /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package/usr/bin/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = "4.0.0" 2 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | from src.app import main 2 | 3 | if __name__ == "__main__": 4 | main() 5 | -------------------------------------------------------------------------------- /resources/logo.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mstuttgart/pynocchio/HEAD/resources/logo.xcf -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | ko_fi: mstuttgart 4 | -------------------------------------------------------------------------------- /.github/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mstuttgart/pynocchio/HEAD/.github/screenshot.png -------------------------------------------------------------------------------- /docs/img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mstuttgart/pynocchio/HEAD/docs/img/screenshot.png -------------------------------------------------------------------------------- /resources/i18n/en_US.qm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mstuttgart/pynocchio/HEAD/resources/i18n/en_US.qm -------------------------------------------------------------------------------- /resources/i18n/es_ES.qm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mstuttgart/pynocchio/HEAD/resources/i18n/es_ES.qm -------------------------------------------------------------------------------- /resources/i18n/pt_BR.qm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mstuttgart/pynocchio/HEAD/resources/i18n/pt_BR.qm -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: Question 6 | assignees: '' 7 | 8 | --- 9 | -------------------------------------------------------------------------------- /src/py.typed: -------------------------------------------------------------------------------- 1 | # Required by PEP 561 to show that we have static types 2 | # See https://www.python.org/dev/peps/pep-0561/#packaging-type-information. 3 | # Enables mypy to discover type hints. 4 | -------------------------------------------------------------------------------- /resources/icons/dark/keyboard_arrow_left.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/dark/keyboard_arrow_right.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/light/keyboard_arrow_left.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/light/keyboard_arrow_right.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/dark/first_page.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/dark/last_page.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/light/first_page.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/light/last_page.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/dark/close.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/light/close.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/dark/view_real_size.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/light/view_real_size.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/dark/fullscreen.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/light/fullscreen.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.fpm: -------------------------------------------------------------------------------- 1 | -C package 2 | -s dir 3 | --name pynocchio 4 | --license gpl3 5 | --version 4.0.0 6 | --architecture all 7 | --depends poppler-utils 8 | --description "Minimalis comic reader" 9 | --url "https://github/mstuttgart/pynocchio" 10 | --maintainer "Michell Stuttgart " 11 | --category "graphics" 12 | -------------------------------------------------------------------------------- /resources/icons/dark/forward.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/dark/reply_all.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/light/forward.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/light/reply_all.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/dark/file_open.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/light/file_open.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/dark/fit_page.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/light/fit_page.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/dark/fit_page_height.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/dark/fit_page_width.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/light/fit_page_height.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/light/fit_page_width.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: Bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | ## Expected Result 13 | 14 | 15 | 16 | ## Actual Result 17 | 18 | 19 | 20 | ## Reproduction Steps 21 | 22 | 23 | -------------------------------------------------------------------------------- /package/usr/share/applications/pynocchio.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Pynocchio 3 | GenericName=Comic Book Viewer 4 | Comment=A minimalist comic reader 5 | Exec=pynocchio 6 | Terminal=false 7 | Type=Application 8 | MimeType=application/x-cbz;application/x-cbr;application/x-cbt;application/x-zip;application/x-rar;application/x-tar; 9 | Icon=pynocchio 10 | Categories=Graphics;Viewer; 11 | Keywords=comic;viewer;reader; 12 | Encoding=UTF-8 13 | StartupNotify=false 14 | -------------------------------------------------------------------------------- /resources/icons/dark/two_pager.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/light/two_pager.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/dark/rotate_left.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/dark/rotate_right.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/light/rotate_left.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/light/rotate_right.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/dark/info.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/light/info.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/dark/bug_report.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/light/bug_report.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | ### Added 10 | - Initial changelog creation. 11 | - GitHub Actions workflow for testing PySide6 on Python 3.12. 12 | - Single-page HTML for the Pynocchio project. 13 | 14 | ### Changed 15 | - Updated CONTRIBUTING.md with testing instructions for real API. 16 | 17 | ### Fixed 18 | - N/A 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: New feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Github CI Lint 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | - develop 9 | 10 | pull_request: 11 | 12 | jobs: 13 | build: 14 | name: Build 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: actions/setup-python@v4 20 | with: 21 | python-version: "3.12" 22 | 23 | - name: 'Update Packages' 24 | run: sudo apt update -y 25 | 26 | - name: 'Install Dependencies' 27 | run: sudo apt install -y patchelf libopengl0 libegl-dev libgles2-mesa-dev nuitka 28 | 29 | - name: Install test dependencies for linting 30 | run: | 31 | pip install ".[dev]" 32 | 33 | - name: Lint with ruff 34 | run: make lint 35 | -------------------------------------------------------------------------------- /src/models/comic_handler_single_page.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtGui import QPixmap 2 | 3 | from src.models.comic_handler import ComicHandler 4 | 5 | 6 | class ComicHandlerSinglePage(ComicHandler): 7 | """ 8 | Handler for single-page comic navigation. 9 | """ 10 | 11 | def goNextPage(self) -> None: 12 | """ 13 | Go to the next page. 14 | """ 15 | self.setCurrentPageIndex(self._currentPageIndex + 1) 16 | 17 | def goPreviousPage(self) -> None: 18 | """ 19 | Go to the previous page. 20 | """ 21 | self.setCurrentPageIndex(self._currentPageIndex - 1) 22 | 23 | def getCurrentPageImage(self) -> QPixmap: 24 | """ 25 | Get the image of the current page. 26 | 27 | Returns: 28 | QPixmap: The pixmap of the current page. 29 | """ 30 | return self.getCurrentPage().getPixmap() 31 | -------------------------------------------------------------------------------- /tests/test_comic_loader.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from src.models.comic_loader import ComicLoader 4 | from src.models.page import Page 5 | 6 | 7 | @pytest.fixture 8 | def comic_loader(): 9 | return ComicLoader("dummy_file") 10 | 11 | 12 | def test_initialization(comic_loader): 13 | """ 14 | Test that ComicLoader initializes with an empty data list. 15 | """ 16 | assert comic_loader.getData() == [] 17 | 18 | 19 | def test_set_data(comic_loader): 20 | """ 21 | Test that setData correctly sets the data. 22 | """ 23 | pages = [Page(b"123", "page 01", 1), Page(b"123", "page 02", 2)] # Mock Page instances 24 | comic_loader.setData(pages) 25 | assert comic_loader.getData() == pages 26 | 27 | 28 | def test_get_data(comic_loader): 29 | """ 30 | Test that getData returns the correct data. 31 | """ 32 | pages = [Page(b"123", "page 01", 1), Page(b"123", "page 02", 2)] # Mock Page instances 33 | comic_loader.setData(pages) 34 | assert comic_loader.getData() == pages 35 | 36 | 37 | def test_load_raises_not_implemented_error(comic_loader): 38 | """ 39 | Test that calling load raises NotImplementedError. 40 | """ 41 | with pytest.raises(NotImplementedError, match="Subclasses must implement this method."): 42 | comic_loader.load("dummy_file") 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # Project 6 | *.idea 7 | .fuse* 8 | # C extensions 9 | *.so 10 | *.save 11 | *.xml 12 | 13 | # Distribution / packaging 14 | .Python 15 | env/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 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 | .coverage 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | 60 | # Diretory files 61 | .directory 62 | 63 | # Config files 64 | # Database files 65 | *.db 66 | 67 | *.py~ 68 | *.xml~ 69 | *.deb 70 | *.rpm 71 | *.pacman 72 | env*/ 73 | .venv 74 | .python-version 75 | .vscode 76 | *.bin 77 | .backup 78 | .env* 79 | 80 | package/usr/bin/* 81 | .flatpak-builder 82 | repo 83 | builddir 84 | dist 85 | flatpak 86 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 4 | 5 | ## Proposed change 6 | 7 | 11 | 12 | Your PR description goes here. 13 | 14 | ## Type of change 15 | 16 | 21 | 22 | - [ ] Existing code/documentation/test/process quality improvement (best practice, cleanup, refactoring, optimization) 23 | - [ ] Dependency update (version deprecation/pin/upgrade) 24 | - [ ] Bugfix (non-breaking change which fixes an issue) 25 | - [ ] Breaking change (a code change causing existing functionality to break) 26 | - [ ] New feature (new `pynocchio` functionality in general) 27 | 28 | ## Checklist 29 | 30 | 33 | 34 | - [ ] I've followed the [contributing guidelines][contributing-guidelines] 35 | - [ ] I've successfully run `make check`, all checks and tests are green 36 | 37 | 40 | 41 | [contributing-guidelines]: https://github.com/mstuttgart/pynocchio/blob/dev/CONTRIBUTING.md 42 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Github CI Coverage 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | - develop 9 | 10 | pull_request: 11 | 12 | jobs: 13 | build: 14 | name: Build 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set QT_QPA_PLATFORM environment variable 21 | run: echo "QT_QPA_PLATFORM=offscreen" >> $GITHUB_ENV 22 | 23 | - uses: actions/setup-python@v5.5.0 24 | with: 25 | python-version: "3.12" 26 | 27 | - name: 'Update Packages' 28 | run: sudo apt update -y 29 | 30 | - name: 'Install Dependencies' 31 | run: sudo apt install -y patchelf libopengl0 libegl-dev libgles2-mesa-dev nuitka 32 | 33 | - name: Install coverage dependencies 34 | run: | 35 | pip install ".[dev]" 36 | pip install ".[coverage]" 37 | 38 | - name: Run coverage tests 39 | run: make coverage 40 | 41 | - name: Upload coverage to Codecov 42 | uses: codecov/codecov-action@v5 43 | with: 44 | fail_ci_if_error: false 45 | token: ${{ secrets.CODECOV_TOKEN }} 46 | 47 | - name: Upload test results to Codecov 48 | if: ${{ !cancelled() }} 49 | uses: codecov/test-results-action@v1 50 | with: 51 | token: ${{ secrets.CODECOV_TOKEN }} 52 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # Exclude specific directories from pre-commit checks 2 | exclude: "docs/" 3 | 4 | repos: 5 | # Pre-commit hooks for general code quality checks 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v5.0.0 8 | hooks: 9 | - id: check-yaml # Validate YAML files 10 | - id: debug-statements # Detect debug statements (e.g., print, pdb) 11 | - id: end-of-file-fixer # Ensure files end with a newline 12 | - id: trailing-whitespace # Remove trailing whitespace 13 | - id: check-builtin-literals # Check for unnecessary use of built-in literals 14 | - id: mixed-line-ending # Fix mixed line endings 15 | args: 16 | - --fix=lf # Convert to LF line endings 17 | 18 | # Sort imports automatically 19 | - repo: https://github.com/PyCQA/isort 20 | rev: 6.0.1 21 | hooks: 22 | - id: isort 23 | 24 | # Format Python code with Black 25 | - repo: https://github.com/psf/black 26 | rev: 25.1.0 27 | hooks: 28 | - id: black 29 | 30 | # Lint Python code with Ruff 31 | - repo: https://github.com/astral-sh/ruff-pre-commit 32 | rev: v0.11.7 33 | hooks: 34 | - id: ruff 35 | 36 | # Upgrade Python syntax to modern versions 37 | - repo: https://github.com/asottile/pyupgrade 38 | rev: v3.19.1 39 | hooks: 40 | - id: pyupgrade 41 | args: [--py39-plus] # Target Python 3.9+ syntax 42 | -------------------------------------------------------------------------------- /tests/test_main_model.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | import pytest 4 | 5 | from src.models.comic import Comic 6 | from src.models.comic_handler import ComicHandler 7 | from src.models.main_model import MainModel 8 | 9 | 10 | @pytest.fixture 11 | def main_model(qtbot): 12 | """Fixture for creating a MainModel instance.""" 13 | return MainModel() 14 | 15 | 16 | def test_set_and_get_comic(main_model): 17 | comic = MagicMock(spec=Comic) 18 | main_model.setComic(comic) 19 | assert main_model.getComic() == comic 20 | 21 | 22 | def test_set_and_get_comic_handler(main_model): 23 | handler = MagicMock(spec=ComicHandler) 24 | main_model.setComicHandler(handler) 25 | assert main_model.getComicHandler() == handler 26 | 27 | 28 | def test_set_and_get_current_comic_path(main_model): 29 | comic_path = "/path/to/comic" 30 | main_model.setCurrentComicPath(comic_path) 31 | assert main_model.getCurrentComicPath() == comic_path 32 | 33 | 34 | def test_set_and_get_current_fit_mode(main_model): 35 | fit_mode = "fit_width" 36 | main_model.setCurrentFitMode(fit_mode) 37 | assert main_model.getCurrentFitMode() == fit_mode 38 | 39 | 40 | def test_rotate_page_left(main_model): 41 | main_model.rotatePageLeft() 42 | assert main_model.getPageRotateAngle() == 270 # -90 % 360 = 270 43 | 44 | 45 | def test_rotate_page_right(main_model): 46 | main_model.rotatePageRight() 47 | assert main_model.getPageRotateAngle() == 90 48 | -------------------------------------------------------------------------------- /tests/test_utility.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from src.models.utils import ( 4 | convertStringToBoolean, 5 | fileExist, 6 | getBaseName, 7 | getDirName, 8 | getFileExtension, 9 | getParentPath, 10 | isDir, 11 | joinPath, 12 | pathExist, 13 | ) 14 | 15 | 16 | def test_getFileExtension(): 17 | assert getFileExtension("myfile.zip") == ".zip" 18 | assert getFileExtension("myfile") == "" 19 | 20 | 21 | def test_getDirName(): 22 | assert getDirName("/home/user/myfile.zip") == "/home/user" 23 | assert getDirName("myfile") == "" 24 | 25 | 26 | def test_getBaseName(): 27 | assert getBaseName("/home/user/myfile.zip") == "myfile.zip" 28 | assert getBaseName("/home/user/myfile") == "myfile" 29 | 30 | 31 | def test_getParentPath(): 32 | assert getParentPath("/home/user/myfile.zip") == "/home" 33 | assert getParentPath("/home/user/myfile") == "/home" 34 | 35 | 36 | def test_joinPath(): 37 | assert joinPath("/home", "user", "myfile.zip") == "/home/user/myfile.zip" 38 | assert joinPath("/home", "user", "myfile") == "/home/user/myfile" 39 | 40 | 41 | def test_pathExist(): 42 | assert pathExist("/home") is True 43 | assert pathExist("/foo") is False 44 | 45 | 46 | def test_fileExist(): 47 | assert fileExist("LICENSE") is True 48 | assert fileExist("foo.py") is False 49 | 50 | 51 | def test_isDir(): 52 | assert isDir("/home") is True 53 | assert isDir("LICENSE") is False 54 | 55 | 56 | def test_convertStringToBoolean(): 57 | assert convertStringToBoolean("True") is True 58 | assert convertStringToBoolean("False") is False 59 | with pytest.raises(ValueError): 60 | convertStringToBoolean("true") 61 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | --- 4 | name: Github CI PyTest 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | - develop 11 | 12 | pull_request: 13 | 14 | # Cancel running jobs for the same workflow and branch. 15 | concurrency: 16 | group: ${{ github.workflow }}-${{ github.ref }} 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | test: 21 | runs-on: ${{ matrix.os }} 22 | strategy: 23 | fail-fast: false 24 | 25 | matrix: 26 | python-version: ["3.12"] 27 | qt-lib: [pyside6] 28 | os: [ubuntu-latest] 29 | 30 | steps: 31 | - uses: actions/checkout@v4 32 | 33 | - name: Set QT_QPA_PLATFORM environment variable 34 | run: echo "QT_QPA_PLATFORM=offscreen" >> $GITHUB_ENV 35 | 36 | - name: Set up Python ${{ matrix.python-version }} 37 | uses: actions/setup-python@v5.5.0 38 | with: 39 | python-version: ${{ matrix.python-version }} 40 | 41 | - name: Install Dependencies 42 | run: | 43 | if [ "${{ matrix.os }}" == "ubuntu-latest" ]; then 44 | sudo apt-get update -y 45 | sudo apt-get install -y libgles2-mesa-dev libxcb-cursor0 46 | fi 47 | shell: bash 48 | 49 | - name: Install test dependencies 50 | run: | 51 | pip install ".[dev]" 52 | 53 | - name: "Check PySide6 Installation" 54 | run: | 55 | python -c "from PySide6.QtWidgets import QApplication; app = QApplication([]); print('PySide6 is working!')" 56 | 57 | - name: Run application tests 58 | run: make test 59 | -------------------------------------------------------------------------------- /resources/resources.qrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/dark/bug_report.svg 4 | icons/dark/close.svg 5 | icons/dark/file_open.svg 6 | icons/dark/first_page.svg 7 | icons/dark/fit_page_height.svg 8 | icons/dark/fit_page_width.svg 9 | icons/dark/fit_page.svg 10 | icons/dark/forward.svg 11 | icons/dark/fullscreen.svg 12 | icons/dark/info.svg 13 | icons/dark/keyboard_arrow_left.svg 14 | icons/dark/keyboard_arrow_right.svg 15 | icons/dark/last_page.svg 16 | icons/dark/reply_all.svg 17 | icons/dark/rotate_left.svg 18 | icons/dark/rotate_right.svg 19 | icons/dark/view_real_size.svg 20 | 21 | 22 | icons/light/bug_report.svg 23 | icons/light/close.svg 24 | icons/light/file_open.svg 25 | icons/light/first_page.svg 26 | icons/light/fit_page_height.svg 27 | icons/light/fit_page_width.svg 28 | icons/light/fit_page.svg 29 | icons/light/forward.svg 30 | icons/light/fullscreen.svg 31 | icons/light/info.svg 32 | icons/light/keyboard_arrow_left.svg 33 | icons/light/keyboard_arrow_right.svg 34 | icons/light/last_page.svg 35 | icons/light/reply_all.svg 36 | icons/light/rotate_left.svg 37 | icons/light/rotate_right.svg 38 | icons/light/view_real_size.svg 39 | 40 | 41 | logo.svg 42 | 43 | 44 | i18n/en_US.qm 45 | i18n/es_ES.qm 46 | i18n/pt_BR.qm 47 | 48 | 49 | -------------------------------------------------------------------------------- /tests/test_comic_page_handler_single_page.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from PySide6.QtGui import QPixmap 3 | 4 | from src.models.comic import Comic 5 | from src.models.comic_handler_single_page import ComicHandlerSinglePage 6 | from src.models.page import Page 7 | 8 | 9 | @pytest.fixture 10 | def comic_handler(qtbot): 11 | """ 12 | Fixture to set up a ComicHandlerSinglePage object with a mock comic and pages. 13 | """ 14 | comic = Comic("comic_test", "comic_dir") 15 | page_1 = Page(b"zyz", "page_title_1", 1) 16 | page_2 = Page(b"zyz", "page_title_2", 2) 17 | page_3 = Page(b"zyz", "page_title_3", 3) 18 | 19 | comic.setPages([page_1, page_2, page_3]) 20 | 21 | obj = ComicHandlerSinglePage(comic) 22 | 23 | return obj 24 | 25 | 26 | def test_go_next_page(comic_handler): 27 | """ 28 | Test that the goNextPage method correctly updates the current page index. 29 | """ 30 | comic_handler.goNextPage() 31 | assert comic_handler.getCurrentPageIndex() == 1 32 | 33 | comic_handler.goNextPage() 34 | assert comic_handler.getCurrentPageIndex() == 2 35 | 36 | comic_handler.goNextPage() 37 | assert comic_handler.getCurrentPageIndex() == 2 # Should not exceed the last page 38 | 39 | 40 | def test_go_previous_page(comic_handler): 41 | """ 42 | Test that the goPreviousPage method correctly updates the current page index. 43 | """ 44 | comic_handler.setCurrentPageIndex(2) 45 | 46 | comic_handler.goPreviousPage() 47 | assert comic_handler.getCurrentPageIndex() == 1 48 | 49 | comic_handler.goPreviousPage() 50 | assert comic_handler.getCurrentPageIndex() == 0 51 | 52 | comic_handler.goPreviousPage() 53 | assert comic_handler.getCurrentPageIndex() == 0 # Should not go below the first page 54 | 55 | 56 | def test_get_current_page_image(comic_handler): 57 | """ 58 | Test that the getCurrentPageImage method returns a QPixmap object. 59 | """ 60 | pixmap = comic_handler.getCurrentPageImage() 61 | assert isinstance(pixmap, QPixmap) # Check if it returns a mocked QPixmap 62 | -------------------------------------------------------------------------------- /src/models/comic_loader_factory.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from src.models.comic_loader import ComicLoader 4 | from src.models.comic_loader_pdf import ComicPdfLoader 5 | from src.models.comic_loader_rar import ComicRarLoader, is_rarfile 6 | from src.models.comic_loader_tar import ComicTarLoader, is_tarfile 7 | from src.models.comic_loader_zip import ComicZipLoader, is_zipfile 8 | from src.models.constants import LOGGING_VERBOSITY, SUPPORTED_FILES 9 | from src.models.utils import getFileExtension 10 | 11 | logger = logging.getLogger(__name__) 12 | logger.setLevel(LOGGING_VERBOSITY) 13 | 14 | 15 | class ComicLoaderFactory: 16 | """ 17 | Factory class to create appropriate comic file loaders based on file type. 18 | """ 19 | 20 | @staticmethod 21 | def createLoader(filename: str) -> ComicLoader: 22 | """ 23 | Creates and returns the appropriate loader for the given comic file. 24 | 25 | Args: 26 | filename (str): The path to the comic file. 27 | 28 | Returns: 29 | ComicLoader: An instance of the appropriate loader class. 30 | 31 | Raises: 32 | TypeError: If the file format is not supported. 33 | """ 34 | file_extension = getFileExtension(filename) 35 | 36 | if file_extension not in SUPPORTED_FILES: 37 | logger.error(f"Unsupported file format: {file_extension}") 38 | raise TypeError(f"Unsupported file format: {file_extension}") 39 | 40 | if file_extension == ".pdf": 41 | logger.info("Creating PDF loader") 42 | return ComicPdfLoader(filename) 43 | 44 | if is_zipfile(filename): 45 | logger.info("Creating ZIP loader") 46 | return ComicZipLoader(filename) 47 | 48 | if is_rarfile(filename): 49 | logger.info("Creating RAR loader") 50 | return ComicRarLoader(filename) 51 | 52 | if is_tarfile(filename): 53 | logger.info("Creating TAR loader") 54 | return ComicTarLoader(filename) 55 | 56 | logger.error(f"Unsupported file type: {file_extension}") 57 | raise TypeError(f"Unsupported file type: {file_extension}") 58 | -------------------------------------------------------------------------------- /tests/test_page.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | from PySide6.QtGui import QImage 5 | 6 | from src.models.page import Page 7 | 8 | 9 | @pytest.fixture 10 | def page(qtbot): 11 | """ 12 | Fixture to create a Page object. 13 | """ 14 | return Page(data=b"xyz", title="title", number=1) 15 | 16 | 17 | def test_create_page(page): 18 | """ 19 | Test creating a Page object. 20 | """ 21 | assert page.getData() == b"xyz" 22 | assert page.getTitle() == "title" 23 | assert page.getNumber() == 1 24 | 25 | 26 | def test_set_title(page): 27 | """ 28 | Test setting the title of a Page object. 29 | """ 30 | new_title = "new title" 31 | page.setTitle(new_title) 32 | assert page.getTitle() == new_title 33 | 34 | 35 | def test_getTitle(page): 36 | """ 37 | Test getting the title of a Page object. 38 | """ 39 | assert page.getTitle() == "title" 40 | 41 | 42 | def test_getNumber(page): 43 | """ 44 | Test getting the number of a Page object. 45 | """ 46 | assert page.getNumber() == 1 47 | 48 | 49 | def test_getData(page): 50 | """ 51 | Test getting the data of a Page object. 52 | """ 53 | assert page.getData() == b"xyz" 54 | 55 | 56 | # TOFIXME Fix QImage create on this test 57 | # def test_get_pixmap_with_valid_data(page): 58 | # """ 59 | # Test getPixmap with valid image data. 60 | # """ 61 | # with ( 62 | # patch("PySide6.QtGui.QImage.loadFromData") as mock_load, 63 | # patch("PySide6.QtGui.QPixmap.fromImage") as mock_from_image, 64 | # ): 65 | # mock_load.return_value = True 66 | # mock_from_image.return_value = QPixmap("resources/logo.png") 67 | 68 | # pixmap = page.getPixmap() 69 | # assert not pixmap.isNull() 70 | 71 | # mock_load.assert_called_once_with(page.getData()) 72 | # mock_from_image.assert_called_once() 73 | 74 | 75 | def test_get_pixmap_with_invalid_data(page): 76 | """ 77 | Test getPixmap with invalid image data. 78 | """ 79 | 80 | with patch.object(QImage, "loadFromData", return_value=True) as mock_load: 81 | pixmap = page.getPixmap() 82 | assert pixmap.isNull() 83 | mock_load.assert_called_once_with(page.getData()) 84 | -------------------------------------------------------------------------------- /src/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | from PySide6.QtCore import QLocale, QTranslator 5 | from PySide6.QtWidgets import QApplication 6 | 7 | import src.app_rc # noqa: F401 8 | from src.__version__ import __version__ 9 | from src.controllers.main_controller import MainController 10 | from src.models.constants import APP_NAME, LANGUAGE, Language 11 | from src.models.main_model import MainModel 12 | from src.views.main_window_view import MainWindowView 13 | 14 | logging.basicConfig(level=logging.INFO) 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class App(QApplication): 19 | """ 20 | A custom QApplication subclass that serves as the main application class. 21 | 22 | This class initializes the application with the specified system arguments, 23 | sets up the organization and application names, and creates the main model, 24 | controller, and main window view for the application. 25 | """ 26 | 27 | def __init__(self, sys_argv): 28 | super().__init__(sys_argv) 29 | 30 | self.setOrganizationName(APP_NAME) 31 | self.setApplicationName(APP_NAME) 32 | self.setApplicationVersion(__version__) 33 | 34 | self.setStyle("Fusion") 35 | 36 | 37 | def main() -> None: 38 | """ 39 | Main function to run the application. 40 | This function initializes the QApplication, sets up internationalization, 41 | creates the main model and controller, and shows the main window view. 42 | """ 43 | # Initialize the application 44 | app: App = App(sys.argv) 45 | 46 | # Internationalization 47 | translator: QTranslator = QTranslator(app) 48 | 49 | if LANGUAGE == Language.AUTO: 50 | if translator.load(QLocale.system(), ":/translations/i18n/"): 51 | logger.info("Loaded translation file.") 52 | app.installTranslator(translator) 53 | else: 54 | logger.warning("Failed to load translation file.") 55 | 56 | elif LANGUAGE != Language.ENGLISH: 57 | if translator.load(f":/translations/i18n/{LANGUAGE}.qm"): 58 | logger.info("Loaded translation file.") 59 | app.installTranslator(translator) 60 | else: 61 | logger.warning("Failed to load translation file.") 62 | 63 | mainModel: MainModel = MainModel() 64 | mainController: MainController = MainController(mainModel) 65 | 66 | mainWindowView: MainWindowView = MainWindowView(mainController) 67 | 68 | mainWindowView.show() 69 | 70 | sys.exit(app.exec()) 71 | 72 | 73 | if __name__ == "__main__": 74 | main() 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 |
4 | 5 | Pynocchio Logo 6 | 7 |
8 | Pynocchio 9 |
10 |

11 | 12 |

A minimalist comic reader

13 | 14 |

15 | 16 | GitHub Workflow Status 17 | 18 | 19 | PySide 6.9 20 | 21 | 22 | GitHub All Releases 23 | 24 | 25 | License 26 | 27 |

28 | 29 |

30 | Features | 31 | Installation | 32 | Contributing | 33 | Credits 34 |

35 | 36 |

37 | Pynocchio Comic Reader - Main Screen 38 |

39 | 40 |

41 | This screenshot contains a page of the webcomic Pepper&Carrot by David Revoy, licensed under the Creative Commons Attribution 4.0 International (CC BY 4.0). 42 |

43 | 44 | ## Features 45 | 46 | The current version is stable, and we aim to improve it further. 47 | 48 | * Supports several view adjustment modes with anti-aliasing. 49 | * Compatible with multiple image formats supported: WEBP, JPG, JPEG, PNG, GIF, BMP, PBM, PGM, PPM, XBM, XPM. 50 | * Supports various comic archive formats: `.ZIP`, `.RAR`, `.TAR`, `.CBT`, `.CBR`, `.CBZ`. 51 | * Support PDF comic files. 52 | * Minimalist design, free, and easy to use! 53 | 54 | ## Installation 55 | 56 | Download latest release [here](https://github.com/mstuttgart/pynocchio/releases). 57 | 58 | 59 | ## Contributing 60 | 61 | If you'd like to contribute, please see the [CONTRIBUTING](https://github.com/mstuttgart/pynocchio/blob/develop/CONTRIBUTING) file. 62 | 63 | ## Credits 64 | 65 | Copyright (C) 2014-2025 by Michell Stuttgart 66 | -------------------------------------------------------------------------------- /tests/test_main_window_view.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | import pytest 4 | from PySide6.QtWidgets import QApplication 5 | 6 | import src.app_rc # noqa: F401 7 | from src.controllers.main_controller import MainController 8 | from src.models.main_model import MainModel 9 | from src.views.main_window_view import MainWindowView 10 | 11 | 12 | @pytest.fixture 13 | def app(qtbot): 14 | """Fixture for creating a QApplication instance.""" 15 | return QApplication.instance() or QApplication([]) 16 | 17 | 18 | @pytest.fixture 19 | def main_model(): 20 | """Fixture for creating a mock MainModel.""" 21 | model = MagicMock(spec=MainModel) 22 | model.updateMainView = MagicMock() 23 | return model 24 | 25 | 26 | @pytest.fixture 27 | def main_controller(): 28 | """Fixture for creating a mock MainController.""" 29 | return MagicMock(spec=MainController) 30 | 31 | 32 | @pytest.fixture 33 | def main_window_view(qtbot): 34 | """Fixture for creating the MainWindowView.""" 35 | settings_manager = MagicMock() 36 | window = MainWindowView(settings_manager) 37 | qtbot.addWidget(window) 38 | return window 39 | 40 | 41 | def test_setup_ui(main_window_view): 42 | """Test the setupUI method.""" 43 | assert main_window_view.windowTitle() == "Pynocchio" 44 | # assert main_window_view.menuBar() is not None 45 | assert main_window_view.centralWidget() is not None 46 | 47 | 48 | def test_setup_actions(main_window_view): 49 | """Test the setupActions method.""" 50 | assert main_window_view._actionExit.text() == "Exit" 51 | assert main_window_view._actionOpenFile.text() == "Open File" 52 | assert main_window_view._actionAbout.text() == "About" 53 | assert main_window_view._actionReportBug.text() == "Report a Bug" 54 | assert main_window_view._actionPreviousPage.text() == "Previous Page" 55 | assert main_window_view._actionNextPage.text() == "Next Page" 56 | assert main_window_view._actionFirstPage.text() == "First Page" 57 | assert main_window_view._actionLastPage.text() == "Last Page" 58 | 59 | 60 | def test_action_exit_triggered(main_window_view, qtbot): 61 | """Test the Exit action.""" 62 | with qtbot.waitSignal(main_window_view._actionExit.triggered, timeout=0): 63 | main_window_view._actionExit.trigger() 64 | 65 | def test_fit_page_enum(): 66 | """Test the FitPage enum.""" 67 | assert MainWindowView.FitPage.FITVERTICAL.value == "actionFitVertical" 68 | assert MainWindowView.FitPage.FITHORIZONTAL.value == "actionFitHorizontal" 69 | assert MainWindowView.FitPage.FITORIGINAL.value == "actionFitOriginal" 70 | assert MainWindowView.FitPage.FITPAGE.value == "actionFitPage" 71 | -------------------------------------------------------------------------------- /src/models/comic.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from src.models.constants import LOGGING_VERBOSITY 5 | from src.models.page import Page 6 | 7 | logger = logging.getLogger(__name__) 8 | logger.setLevel(LOGGING_VERBOSITY) 9 | 10 | 11 | class Comic: 12 | """ 13 | This is basic class of Pynocchio. Represents a comic object 14 | """ 15 | 16 | def __init__(self, filename: str, directory: str) -> None: 17 | """ 18 | Comic class __init__ method 19 | 20 | Args: 21 | filename (str): comic filename 22 | directory (str): comic file directory 23 | """ 24 | self._filename: str = filename 25 | self._directory: str = directory 26 | 27 | # list of Page: list to store the comic pages objects 28 | self._pages: list[Page] = [] 29 | 30 | def setFilename(self, filename: str) -> None: 31 | """ 32 | Set comic filename. 33 | 34 | Args: 35 | filename (str): The filename to set. 36 | """ 37 | self._filename = filename 38 | 39 | def getFilename(self) -> str: 40 | """ 41 | Get comic filename. 42 | 43 | Returns: 44 | str: The return value. Represents comic filename. 45 | """ 46 | return self._filename 47 | 48 | def setDirectory(self, directory: str) -> None: 49 | """ 50 | Set comic directory. 51 | 52 | Args: 53 | directory (str): The directory to set. 54 | """ 55 | self._directory = directory 56 | 57 | def getDirectory(self) -> str: 58 | """ 59 | Get comic directory. 60 | 61 | Returns: 62 | str: The return value. Represents comic directory. 63 | """ 64 | return self._directory 65 | 66 | def setPages(self, pages: list[Page]) -> None: 67 | """ 68 | Set comic pages. 69 | 70 | Args: 71 | pages (list[Page]): The pages to set. 72 | """ 73 | self._pages = pages 74 | 75 | def getPages(self) -> list[Page]: 76 | """ 77 | Get comic pages. 78 | 79 | Returns: 80 | list[Page]: The return value. Represents comic pages. 81 | """ 82 | return self._pages 83 | 84 | def getComicPath(self) -> str: 85 | """ 86 | Get comic path. 87 | 88 | Returns: 89 | str: The return value. Represents comic path. 90 | """ 91 | return os.path.join(self._directory, self._filename) 92 | 93 | def getPageCount(self) -> int: 94 | """ 95 | Get the number of pages in the comic. 96 | 97 | Returns: 98 | int: The number of pages in the comic. 99 | """ 100 | return len(self._pages) 101 | -------------------------------------------------------------------------------- /tests/test_comic.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from src.models.comic import Comic 6 | from src.models.page import Page 7 | 8 | 9 | @pytest.fixture 10 | def comic_data(qtbot): 11 | filename = "comic.zip" 12 | directory = "comic_dir" 13 | pages = [ 14 | Page(b"123", "page 01", 1), 15 | Page(b"456", "page 02", 2), 16 | ] 17 | comic = Comic(filename, directory) 18 | return comic, filename, directory, pages 19 | 20 | 21 | def test_get_filename(comic_data): 22 | """ 23 | Test the getFilename method. 24 | This test checks if the filename is retrieved correctly. 25 | """ 26 | comic, filename, _, _ = comic_data 27 | assert comic.getFilename() == filename 28 | 29 | 30 | def test_set_filename(comic_data): 31 | """ 32 | Test the setFilename method. 33 | """ 34 | comic, _, _, _ = comic_data 35 | new_filename = "new_comic.zip" 36 | comic.setFilename(new_filename) 37 | assert comic.getFilename() == new_filename 38 | 39 | 40 | def test_get_directory(comic_data): 41 | """ 42 | Test the getDirectory method. 43 | """ 44 | comic, _, directory, _ = comic_data 45 | assert comic.getDirectory() == directory 46 | 47 | 48 | def test_set_directory(comic_data): 49 | """ 50 | Test the setDirectory method. 51 | """ 52 | comic, _, _, _ = comic_data 53 | new_directory = "new_comic_dir" 54 | comic.setDirectory(new_directory) 55 | assert comic.getDirectory() == new_directory 56 | 57 | 58 | def test_get_pages(comic_data): 59 | """ 60 | Test the getPages method. 61 | """ 62 | comic, _, _, _ = comic_data 63 | assert comic.getPages() == [] 64 | 65 | 66 | def test_set_pages(comic_data): 67 | """ 68 | Test the setPages method. 69 | """ 70 | comic, _, _, pages = comic_data 71 | comic.setPages(pages) 72 | assert comic.getPages() == pages 73 | 74 | 75 | def test_get_comic_path(comic_data): 76 | """ 77 | Test the getComicPath method. 78 | """ 79 | comic, filename, directory, _ = comic_data 80 | expected_path = os.path.join(directory, filename) 81 | assert comic.getComicPath() == expected_path 82 | 83 | 84 | def test_comic_initialization(comic_data): 85 | """ 86 | Test the initialization of the Comic class. 87 | """ 88 | comic, filename, directory, _ = comic_data 89 | assert comic.getFilename() == filename 90 | assert comic.getDirectory() == directory 91 | assert comic.getPages() == [] 92 | 93 | 94 | def test_set_and_get_pages(comic_data): 95 | """ 96 | Test setting and getting pages. 97 | """ 98 | comic, _, _, pages = comic_data 99 | comic.setPages(pages) 100 | assert comic.getPages() == pages 101 | assert len(comic.getPages()) == len(pages) 102 | assert all(isinstance(page, Page) for page in comic.getPages()) 103 | -------------------------------------------------------------------------------- /src/models/page.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Union 3 | 4 | from PIL import ImageQt 5 | from PIL.Image import Image 6 | from PySide6.QtGui import QImage, QPixmap 7 | 8 | from src.models.constants import LOGGING_VERBOSITY 9 | 10 | logger = logging.getLogger(__name__) 11 | logger.setLevel(LOGGING_VERBOSITY) 12 | 13 | 14 | class Page: 15 | """ 16 | This is basic class of Pynocchio. Represents a comic page object 17 | """ 18 | 19 | def __init__(self, data: Union[bytes, Image], title: str, number: int): 20 | """ 21 | Comic Page class __init__ method 22 | 23 | Args: 24 | data (bin): comic page binary data 25 | title (str): page title 26 | number (int): page number 27 | """ 28 | self._data: Union[bytes, Image] = data 29 | self._title: str = title 30 | self._number: int = number 31 | self._pixmap: QPixmap = QPixmap() 32 | 33 | def getPixmap(self): 34 | """ 35 | Get page pixmap. 36 | 37 | Returns: 38 | None: The return value. Represents page pixmap. 39 | """ 40 | 41 | if self._pixmap.isNull(): 42 | logger.debug("Loading image from data") 43 | 44 | if isinstance(self._data, bytes): 45 | # Create a QImage from the binary data 46 | # and convert it to a QPixmap 47 | image = QImage() 48 | 49 | if image.loadFromData(self._data): 50 | self._pixmap = QPixmap.fromImage(image) 51 | logger.debug("Image loaded successfully") 52 | else: 53 | logger.error("Failed to load image from data") 54 | 55 | else: 56 | # If the data is not in bytes, convert from PIL Image to QPixmap 57 | # using ImageQt 58 | image = ImageQt.ImageQt(self._data) 59 | self._pixmap = QPixmap.fromImage(image) 60 | logger.debug("Image loaded successfully") 61 | 62 | return self._pixmap 63 | 64 | def getTitle(self) -> str: 65 | """ 66 | Get page title. 67 | 68 | Returns: 69 | str: The return value. Represents page title. 70 | """ 71 | return self._title 72 | 73 | def setTitle(self, title: str) -> None: 74 | """ 75 | Set page title. 76 | 77 | Args: 78 | title (str): The title to set. 79 | """ 80 | self._title = title 81 | 82 | def getNumber(self) -> int: 83 | """ 84 | Get page number. 85 | 86 | Returns: 87 | int: The return value. Represents page number. 88 | """ 89 | return self._number 90 | 91 | def getData(self) -> Union[bytes, Image]: 92 | """ 93 | Get page data. 94 | 95 | Returns: 96 | bytes: The return value. Represents page data. 97 | """ 98 | return self._data 99 | -------------------------------------------------------------------------------- /tests/test_comic_loader_factory.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from src.models.comic_loader_factory import ComicLoaderFactory 6 | from src.models.comic_loader_rar import ComicRarLoader 7 | from src.models.comic_loader_tar import ComicTarLoader 8 | from src.models.comic_loader_zip import ComicZipLoader 9 | 10 | 11 | class TestComicLoaderFactory: 12 | """ 13 | Unit tests for the ComicLoaderFactory class, which is responsible for creating 14 | appropriate loader instances based on the file type. 15 | """ 16 | 17 | @mock.patch("src.models.comic_loader_factory.is_zipfile", lambda filename: True) 18 | def test_create_loader_zip(self): 19 | """ 20 | Test that ComicLoaderFactory creates a ComicZipLoader instance 21 | when the file is identified as a ZIP file. 22 | """ 23 | loader = ComicLoaderFactory.createLoader("test.zip") 24 | assert isinstance(loader, ComicZipLoader) 25 | 26 | @mock.patch("src.models.comic_loader_factory.is_rarfile", lambda filename: True) 27 | def test_create_loader_rar(self): 28 | """ 29 | Test that ComicLoaderFactory creates a ComicRarLoader instance 30 | when the file is identified as a RAR file. 31 | """ 32 | loader = ComicLoaderFactory.createLoader("test.rar") 33 | assert isinstance(loader, ComicRarLoader) 34 | 35 | @mock.patch("src.models.comic_loader_factory.is_tarfile", lambda filename: True) 36 | def test_create_loader_tar(self): 37 | """ 38 | Test that ComicLoaderFactory creates a ComicRarLoader instance 39 | when the file is identified as a TAR file. 40 | """ 41 | loader = ComicLoaderFactory.createLoader("test.tar") 42 | assert isinstance(loader, ComicTarLoader) 43 | 44 | @mock.patch( 45 | "src.models.comic_loader_factory.getFileExtension", 46 | lambda filename: ".unsupported", 47 | ) 48 | def test_create_loader_unsupported_file_format(self): 49 | """ 50 | Test that ComicLoaderFactory raises a TypeError when the file format 51 | is not supported. 52 | """ 53 | with pytest.raises(TypeError, match="Unsupported file format: .unsupported"): 54 | ComicLoaderFactory.createLoader("test.unsupported") 55 | 56 | @mock.patch("src.models.comic_loader_factory.getFileExtension", lambda filename: ".zip") 57 | @mock.patch("src.models.comic_loader_factory.is_zipfile", lambda filename: False) 58 | @mock.patch("src.models.comic_loader_factory.is_rarfile", lambda filename: False) 59 | @mock.patch("src.models.comic_loader_factory.is_tarfile", lambda filename: False) 60 | def test_create_loader_unsupported_file_type(self): 61 | """ 62 | Test that ComicLoaderFactory raises a TypeError when the file type 63 | is not recognized even if the extension is supported. 64 | """ 65 | with pytest.raises(TypeError, match="Unsupported file type: .zip"): 66 | ComicLoaderFactory.createLoader("test.zip") 67 | -------------------------------------------------------------------------------- /src/models/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def getFileExtension(filename: str) -> str: 5 | """ 6 | Get the file extension from a filename. 7 | 8 | Args: 9 | filename (str): The name of the file. 10 | 11 | Returns: 12 | str: The file extension, including the leading dot. 13 | """ 14 | return os.path.splitext(filename)[1] 15 | 16 | 17 | def getDirName(filePath: str) -> str: 18 | """ 19 | Get the directory name from a file path. 20 | 21 | Args: 22 | filePath (str): The full file path. 23 | 24 | Returns: 25 | str: The directory name. 26 | """ 27 | return os.path.dirname(filePath) 28 | 29 | 30 | def getBaseName(filePath: str) -> str: 31 | """ 32 | Get the base name (file name with extension) from a file path. 33 | 34 | Args: 35 | filePath (str): The full file path. 36 | 37 | Returns: 38 | str: The base name of the file. 39 | """ 40 | return os.path.basename(filePath) 41 | 42 | 43 | def getParentPath(filePath: str) -> str: 44 | """ 45 | Get the parent directory path of a given file path. 46 | 47 | Args: 48 | filePath (str): The full file path. 49 | 50 | Returns: 51 | str: The parent directory path. 52 | """ 53 | return os.path.split(os.path.abspath(os.path.dirname(filePath)))[0] 54 | 55 | 56 | def joinPath(rootDir: str, directory: str, filename: str) -> str: 57 | """ 58 | Join root directory, subdirectory, and filename into a single path. 59 | 60 | Args: 61 | rootDir (str): The root directory. 62 | directory (str): The subdirectory. 63 | filename (str): The file name. 64 | 65 | Returns: 66 | str: The combined file path. 67 | """ 68 | return os.path.join(rootDir, directory, filename) 69 | 70 | 71 | def pathExist(filePath: str) -> bool: 72 | """ 73 | Check if a path exists (including broken symbolic links). 74 | 75 | Args: 76 | filePath (str): The path to check. 77 | 78 | Returns: 79 | bool: True if the path exists, False otherwise. 80 | """ 81 | return os.path.lexists(filePath) 82 | 83 | 84 | def fileExist(filePath: str) -> bool: 85 | """ 86 | Check if a file exists. 87 | 88 | Args: 89 | filePath (str): The file path to check. 90 | 91 | Returns: 92 | bool: True if the file exists, False otherwise. 93 | """ 94 | return os.path.exists(filePath) 95 | 96 | 97 | def isDir(filePath: str) -> bool: 98 | """ 99 | Check if a path is a directory. 100 | 101 | Args: 102 | filePath (str): The path to check. 103 | 104 | Returns: 105 | bool: True if the path is a directory, False otherwise. 106 | """ 107 | return os.path.isdir(filePath) 108 | 109 | 110 | def isFile(filename: str) -> bool: 111 | """ 112 | Check if a path is a file. 113 | 114 | Args: 115 | filename (str): The file path to check. 116 | 117 | Returns: 118 | bool: True if the path is a file, False otherwise. 119 | """ 120 | return os.path.isfile(filename) 121 | 122 | 123 | def convertStringToBoolean(string: str) -> bool: 124 | """ 125 | Convert a string to a boolean value. 126 | 127 | Args: 128 | string (str): The string to convert. Must be "True" or "False". 129 | 130 | Returns: 131 | bool: The corresponding boolean value. 132 | 133 | Raises: 134 | ValueError: If the string is not "True" or "False". 135 | """ 136 | if string == "True": 137 | return True 138 | elif string == "False": 139 | return False 140 | else: 141 | raise ValueError(f"Invalid string for boolean conversion: {string}") 142 | -------------------------------------------------------------------------------- /tests/test_comic_page_handler.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from src.models.comic import Comic 4 | from src.models.comic_handler import ComicHandler 5 | from src.models.page import Page 6 | 7 | 8 | @pytest.fixture 9 | def comic_handler(qtbot): 10 | """ 11 | Fixture to set up a ComicHandler instance with a mock Comic object. 12 | """ 13 | comic = Comic("comic_test", "comic_dir") 14 | page_1 = Page(b"zyz", "page_title_1", 1) 15 | page_2 = Page(b"zyz", "page_title_2", 2) 16 | page_3 = Page(b"zyz", "page_title_3", 3) 17 | 18 | comic.setPages([page_1, page_2, page_3]) 19 | 20 | handler = ComicHandler(comic) 21 | 22 | return handler, comic, [page_1, page_2, page_3] 23 | 24 | 25 | def test_get_comic(comic_handler): 26 | """ 27 | Test that the handler returns the correct comic object. 28 | """ 29 | handler, comic, _ = comic_handler 30 | assert handler.getComic() == comic 31 | 32 | 33 | def test_set_comic(comic_handler): 34 | """ 35 | Test that the handler can set a new comic object. 36 | """ 37 | handler, _, _ = comic_handler 38 | new_comic = Comic("new_comic", "new_dir") 39 | handler.setComic(new_comic) 40 | assert handler.getComic() == new_comic 41 | 42 | 43 | def test_get_current_page_index(comic_handler): 44 | """ 45 | Test that the handler returns the correct initial page index. 46 | """ 47 | handler, _, _ = comic_handler 48 | assert handler.getCurrentPageIndex() == 0 49 | 50 | 51 | def test_set_current_page_index_valid(comic_handler): 52 | """ 53 | Test that the handler sets a valid page index correctly. 54 | """ 55 | handler, _, _ = comic_handler 56 | handler.setCurrentPageIndex(2) 57 | assert handler.getCurrentPageIndex() == 2 58 | 59 | 60 | def test_set_current_page_index_invalid(comic_handler): 61 | """ 62 | Test that the handler resets to the first page when an invalid index is set. 63 | """ 64 | handler, _, _ = comic_handler 65 | handler.setCurrentPageIndex(5) 66 | assert handler.getCurrentPageIndex() == 0 67 | 68 | 69 | def test_get_current_page(comic_handler): 70 | """ 71 | Test that the handler returns the correct current page. 72 | """ 73 | handler, _, pages = comic_handler 74 | assert handler.getCurrentPage() == pages[0] 75 | handler.setCurrentPageIndex(1) 76 | assert handler.getCurrentPage() == pages[1] 77 | 78 | 79 | def test_go_first_page(comic_handler): 80 | """ 81 | Test that the handler navigates to the first page. 82 | """ 83 | handler, _, _ = comic_handler 84 | handler.setCurrentPageIndex(2) 85 | handler.goFirstPage() 86 | assert handler.getCurrentPageIndex() == 0 87 | 88 | 89 | def test_go_last_page(comic_handler): 90 | """ 91 | Test that the handler navigates to the last page. 92 | """ 93 | handler, _, _ = comic_handler 94 | handler.goLastPage() 95 | assert handler.getCurrentPageIndex() == 2 96 | 97 | 98 | def test_get_current_page_image_not_implemented(comic_handler): 99 | """ 100 | Test that getCurrentPageImage raises a NotImplementedError. 101 | """ 102 | handler, _, _ = comic_handler 103 | with pytest.raises(NotImplementedError): 104 | handler.getCurrentPageImage() 105 | 106 | 107 | def test_go_next_page_not_implemented(comic_handler): 108 | """ 109 | Test that goNextPage raises a NotImplementedError. 110 | """ 111 | handler, _, _ = comic_handler 112 | with pytest.raises(NotImplementedError): 113 | handler.goNextPage() 114 | 115 | 116 | def test_go_previous_page_not_implemented(comic_handler): 117 | """ 118 | Test that goPreviousPage raises a NotImplementedError. 119 | """ 120 | handler, _, _ = comic_handler 121 | with pytest.raises(NotImplementedError): 122 | handler.goPreviousPage() 123 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help check clean build coverage lint pre-commit setup test run deploy 2 | 3 | # Help target to display available commands 4 | help: 5 | @echo "Usage: make " 6 | @echo "" 7 | @echo "Available targets:" 8 | @echo " check Run pre-commit, docs, tests, and build" 9 | @echo " clean Clean up generated files and caches" 10 | @echo " build Build artefacts distribution" 11 | @echo " coverage Identify code not covered with tests" 12 | @echo " lint Run lint checks on the module" 13 | @echo " lint-hint Run lint checks on the module with hints" 14 | @echo " pre-commit Run pre-commit against all files" 15 | @echo " setup Set up the development environment" 16 | @echo " test Run tests (in parallel)" 17 | @echo " run Run application" 18 | @echo " deploy Build executable of pynocchio" 19 | @echo " lupdate Update translation files" 20 | @echo " lrelease Generate .qm file from .ts file" 21 | @echo " rcc Run rcc to compile the resources .qrc file" 22 | @echo " help Show this summary of available commands" 23 | 24 | # Run all checks 25 | check: 26 | $(MAKE) pre-commit 27 | $(MAKE) test 28 | $(MAKE) build 29 | 30 | # Clean up generated files and caches 31 | clean: 32 | find . -name "*.mo" -delete 33 | find . -name "*.pyc" -delete 34 | rm -rf .mypy_cache/ .pytest_cache/ .ruff_cache/ dist/ tox/ 35 | rm -rf .coverage .coverage.xml htmlcov junit.xml 36 | rm -rf .pytest_cache/ .ruff_cache/ .mypy_cache/ 37 | rm -rf build repo flatpak .flatpak-builder builddir 38 | pyside6-project clean . 39 | rm pynocchio.db 40 | rm pynocchio.deb 41 | 42 | # Build package distribution 43 | build: 44 | rm -rf *.egg-info/ 45 | pyside6-project build . 46 | $(MAKE) lrelease 47 | $(MAKE) rcc 48 | 49 | # Run coverage analysis 50 | coverage: 51 | pytest --cov=src --cov-config=pyproject.toml --cov-report=term-missing --no-cov-on-fail --cov-report=html --junitxml=junit.xml -o junit_family=legacy 52 | 53 | # Run lint checks 54 | lint: 55 | isort --check src tests 56 | black --check src tests 57 | ruff check src tests 58 | 59 | lint-hint: 60 | mypy src --ignore-missing-imports --explicit-package-bases 61 | 62 | # Run pre-commit hooks 63 | pre-commit: 64 | pre-commit run --all-files 65 | 66 | # Set up the development environment 67 | setup: 68 | pip install -e ".[dev]" 69 | pip install -e ".[coverage]" 70 | pip install -e ".[build]" 71 | pre-commit install --hook-type pre-commit 72 | pre-commit install --hook-type pre-push 73 | pre-commit autoupdate 74 | mypy --install-types . 75 | sudo apt install -y poppler-utils # dependencies for reading pdf files 76 | 77 | # Run tests 78 | test: 79 | pytest -v 80 | 81 | # Run pynocchio 82 | run: 83 | pyside6-project run 84 | 85 | # run rcc to compile the resources .qrc file 86 | rcc: 87 | pyside6-rcc resources/resources.qrc -o src/app_rc.py 88 | 89 | # build executable of pynocchio 90 | deploy: 91 | $(MAKE) lrelease 92 | $(MAKE) rcc 93 | rm -rf build 94 | mkdir -p build 95 | pyside6-project deploy . 96 | 97 | # build .deb packages 98 | packages: 99 | cp -f build/Pynocchio.bin package/usr/bin/pynocchio 100 | cp -f resources/logo.svg package/usr/share/icons/hicolor/scalable/apps/pynocchio.svg 101 | rm -f *.deb 102 | rm -f *.rpm 103 | rm -f *.pacman 104 | fpm -t deb -p pynocchio-v4.0.0-amd64.deb 105 | fpm -t rpm -p pynocchio-v4.0.0-amd64.rpm 106 | 107 | # Update and create translation files 108 | lupdate: 109 | pyside6-lupdate src/views/main_window_view.py -ts i18n/*.ts 110 | 111 | # Generate .qm file from .ts file 112 | lrelease: 113 | pyside6-lrelease i18n/en_US.ts -qm resources/i18n/en_US.qm 114 | pyside6-lrelease i18n/es_ES.ts -qm resources/i18n/es_ES.qm 115 | pyside6-lrelease i18n/pt_BR.ts -qm resources/i18n/pt_BR.qm 116 | -------------------------------------------------------------------------------- /src/models/reading_list_model.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import sqlite3 4 | from typing import Optional 5 | 6 | from src.models.constants import DATABASE_FILE_NAME, LOGGING_VERBOSITY 7 | 8 | TABLE_NAME = "ReadingList" 9 | 10 | # Configure logger 11 | logger = logging.getLogger(__name__) 12 | logger.setLevel(LOGGING_VERBOSITY) 13 | 14 | 15 | def createTable() -> None: 16 | """ 17 | Create the ReadingList table in the database if it does not already exist. 18 | 19 | This function ensures the database table is created with the required schema. 20 | """ 21 | with sqlite3.connect(DATABASE_FILE_NAME) as connection: 22 | cursor = connection.cursor() 23 | 24 | logger.info("Database created and connected successfully!") 25 | 26 | cursor.execute( 27 | f""" 28 | CREATE TABLE IF NOT EXISTS {TABLE_NAME} ( 29 | id INTEGER PRIMARY KEY AUTOINCREMENT, 30 | name TEXT NOT NULL, 31 | path TEXT NOT NULL, 32 | readDate TEXT NOT NULL, 33 | page INTEGER NOT NULL 34 | ) 35 | """ 36 | ) 37 | 38 | # Commit the changes automatically 39 | connection.commit() 40 | 41 | 42 | def addEntry(name: str, path: str, page: int = 0) -> None: 43 | """ 44 | Add or update an entry in the reading list. 45 | 46 | If an entry with the given path already exists, it updates the page and readDate. 47 | Otherwise, it adds a new entry. 48 | 49 | Args: 50 | name (str): The name of the comic. 51 | path (str): The path to the comic file. 52 | page (int, optional): The last page read. Defaults to 0. 53 | """ 54 | with sqlite3.connect(DATABASE_FILE_NAME) as connection: 55 | logger.info("Database created and connected successfully!") 56 | 57 | cursor = connection.cursor() 58 | 59 | # Check if the entry already exists 60 | result = cursor.execute(f"SELECT id FROM {TABLE_NAME} WHERE path = ?", (path,)).fetchall() 61 | 62 | if result: 63 | logger.info("Entry already exists in database") 64 | 65 | for res in result: 66 | query = f""" 67 | UPDATE {TABLE_NAME} 68 | SET page = ?, readDate = ? 69 | WHERE id = ? 70 | """ 71 | cursor.execute(query, (page, datetime.datetime.now(), res[0])) 72 | 73 | else: 74 | logger.info("Adding new entry to database") 75 | 76 | cursor.execute( 77 | f""" 78 | INSERT INTO {TABLE_NAME} (name, path, readDate, page) 79 | VALUES (?, ?, ?, ?) 80 | """, 81 | (name, path, datetime.datetime.now(), page), 82 | ) 83 | 84 | # Commit the changes automatically 85 | connection.commit() 86 | 87 | 88 | def getLastComicPage(path: str) -> Optional[int]: 89 | """ 90 | Retrieve the last page number read for a given comic path. 91 | 92 | Args: 93 | path (str): The path to the comic file. 94 | 95 | Returns: 96 | Optional[int]: The last page number read, or None if the entry does not exist. 97 | """ 98 | with sqlite3.connect(DATABASE_FILE_NAME) as connection: 99 | logger.info("Database created and connected successfully!") 100 | 101 | cursor = connection.cursor() 102 | 103 | # Query the database for the page number 104 | result = cursor.execute(f"SELECT page FROM {TABLE_NAME} WHERE path = ?", (path,)).fetchone() 105 | 106 | if result: 107 | logger.info("Entry found in database") 108 | return result[0] 109 | else: 110 | logger.info("Entry not found in database") 111 | return None 112 | 113 | 114 | # Ensure the table is created when the module is loaded 115 | createTable() 116 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pynocchio" 7 | description = "Pynocchio is a image viewer specialized in comic reading" 8 | readme = "README.md" 9 | license = { file = "LICENSE" } 10 | authors = [{ name = "Michell Stuttgart", email = "michellstut@gmail.com" }] 11 | requires-python = ">=3.9" 12 | dynamic = ["version"] 13 | classifiers = [ 14 | "Development Status :: 5 - Production/Stable", 15 | 'Intended Audience :: Users', 16 | 'License :: OSI Approved :: GPLv3 License', 17 | 'Operating System :: OS Independent', 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3 :: Only", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | "Programming Language :: Python :: 3.13", 26 | "Natural Language :: English", 27 | ] 28 | dependencies = [ 29 | "pyside6-essentials==6.9.0", 30 | "pywin32>=310; sys_platform == 'win32'", 31 | "rarfile==4.2", 32 | "python-dotenv==1.1.0", 33 | "pdf2image==1.17.0", 34 | ] 35 | 36 | [project.optional-dependencies] 37 | dev = [ 38 | "pytest>=7.3.1", 39 | "pytest-qt", 40 | "PySide6-stubs", 41 | "mypy>=1.0,<1.5", 42 | "mypy-extensions", 43 | "black>=23.0,<24.0", 44 | "isort>=5.12,<5.13", 45 | "pre-commit>=4.0.1", 46 | "ruff", 47 | "tomlkit", 48 | "PyInstaller", 49 | ] 50 | 51 | coverage = ["coveralls>=3.3.1", "pytest-cov>=4.0.0", "codecov-cli>=2.0.15"] 52 | 53 | build = ["build==1.2.2.post1"] 54 | 55 | [project.urls] 56 | Repository = "https://github.com/pynocchio/pynocchio" 57 | Tracker = "https://github.com/pynocchio/pynocchio/issues" 58 | Changelog = "https://github.com/pynocchio/pynocchio/blob/main/CHANGELOG" 59 | 60 | [tool.check-manifest] 61 | ignore = [".*", "tests/**", "docs/**", "bin/**", "*.yml", ".pylintrc"] 62 | 63 | [tool.setuptools] 64 | include-package-data = true 65 | 66 | [tool.setuptools.packages.find] 67 | exclude = ["*.tests", "*.tests.*", "tests.*", "tests", "docs*", "scripts*"] 68 | 69 | [tool.setuptools.package-data] 70 | src = ["py.typed"] 71 | 72 | [tool.setuptools.dynamic] 73 | version = { attr = "src.__version__.__version__" } 74 | 75 | [tool.black] 76 | line-length = 100 77 | include = '\.pyi?$' 78 | exclude = ''' 79 | ( 80 | __pycache__ 81 | | \.git 82 | | \.mypy_cache 83 | | \.pytest_cache 84 | | \.vscode 85 | | \.venv 86 | | \bdist\b 87 | | \bdoc\b 88 | | \.nox 89 | | \.github 90 | ) 91 | ''' 92 | 93 | [tool.isort] 94 | profile = "black" 95 | multi_line_output = 3 96 | skip = ["docs"] 97 | 98 | [tool.pyright] 99 | reportPrivateImportUsage = false 100 | 101 | [tool.mypy] 102 | strict = false 103 | 104 | [tool.ruff] 105 | # Exclude a variety of commonly ignored directories. 106 | exclude = [ 107 | ".bzr", 108 | ".direnv", 109 | ".eggs", 110 | ".git", 111 | ".git-rewrite", 112 | ".hg", 113 | ".ipynb_checkpoints", 114 | ".mypy_cache", 115 | ".nox", 116 | ".pants.d", 117 | ".pyenv", 118 | ".pytest_cache", 119 | ".pytype", 120 | ".ruff_cache", 121 | ".svn", 122 | ".tox", 123 | ".venv", 124 | ".vscode", 125 | "__pypackages__", 126 | "_build", 127 | "buck-out", 128 | "build", 129 | "dist", 130 | "node_modules", 131 | "site-packages", 132 | "venv", 133 | ] 134 | 135 | 136 | [[tool.mypy.overrides]] 137 | module = "tests.*" 138 | strict_optional = false 139 | 140 | [tool.coverage.run] 141 | branch = true 142 | omit = [ 143 | "tests/*", 144 | "src/app_rc.py", 145 | "src/__init__.py", 146 | "src/app.py", 147 | "src/__version__.py", 148 | ] 149 | 150 | [tool.pyside6-project] 151 | files = ["run.py", "src/views/main_window_view.py"] 152 | -------------------------------------------------------------------------------- /src/models/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module for application settings and configuration. 3 | 4 | This module defines constants, paths, and settings used throughout the application. 5 | It dynamically determines the configuration folder and file locations based on the 6 | debugging state. 7 | 8 | Attributes: 9 | DEBUG (bool): Indicates whether the application is in debug mode. 10 | LOGGING_VERBOSITY (int): Logging verbosity level. 11 | YEAR (int): Current year. 12 | AUTHOR (str): Author of the application. 13 | VERSION (str): Application version. 14 | APP_NAME (str): Name of the application. 15 | DATABASE_FILE_NAME (str): Name of the database file. 16 | CONFIG_FILE_NAME (str): Name of the configuration file. 17 | HELP_URL (str): URL for help documentation. 18 | FEEDBACK_URL (str): URL for submitting feedback or issues. 19 | RELEASE_URL (str): URL for the latest release. 20 | LICENSE_URL (str): URL for license information. 21 | COPYRIGHT (str): Copyright information. 22 | IMAGE_FILE_FORMATS (list[str]): Supported image file formats. 23 | COMPACT_FILE_FORMATS (list[str]): Supported compact file formats. 24 | SUPPORTED_FILES (list[str]): All supported file formats. 25 | CONFIG_FOLDER (Path): Path to the configuration folder. 26 | CONFIG_FILE (Path): Full path to the configuration file. 27 | DATABASE_FILE (Path): Full path to the database file. 28 | """ 29 | 30 | import datetime 31 | import enum 32 | import os 33 | from pathlib import Path 34 | from typing import Union 35 | 36 | from dotenv import load_dotenv 37 | from PySide6.QtCore import QStandardPaths 38 | 39 | from src.__version__ import __version__ 40 | 41 | # Load environment variables from a .env file 42 | load_dotenv() 43 | 44 | # Debugging and logging configuration 45 | DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true" 46 | LOGGING_VERBOSITY: Union[int, str] = os.getenv("LOGGING_VERBOSITY", "INFO").upper() 47 | LANGUAGE: str = os.getenv("LANGUAGE", "Auto") 48 | 49 | # Application metadata 50 | YEAR: int = datetime.datetime.now().year 51 | AUTHOR: str = "Michell Stuttgart" 52 | VERSION: str = __version__ 53 | APP_NAME: str = "Pynocchio" 54 | 55 | # File names 56 | DATABASE_FILE_NAME: str = f"{APP_NAME.lower()}.db" 57 | CONFIG_FILE_NAME: str = f"{APP_NAME.lower()}.json" 58 | 59 | # URLs 60 | HELP_URL: str = "https://github.com/mstuttgart/pynocchio/issues" 61 | FEEDBACK_URL: str = "https://github.com/mstuttgart/pynocchio/issues" 62 | RELEASE_URL: str = "https://github.com/mstuttgart/pynocchio/releases/latest" 63 | LICENSE_URL: str = "https://github.com/mstuttgart/pynocchio/blob/develop/LICENSE" 64 | COPYRIGHT: str = f"Copyright (C) 2014-{YEAR} {AUTHOR}" 65 | 66 | # Image file formats (supported by the application) 67 | IMAGE_FILE_FORMATS: list[str] = [ 68 | ".bmp", 69 | ".jpg", 70 | ".jpeg", 71 | ".png", 72 | ".gif", 73 | ".webp", 74 | ] 75 | 76 | # Compact file formats (compressed archives) 77 | COMPACT_FILE_FORMATS: list[str] = [ 78 | ".cbr", 79 | ".cbz", 80 | ".rar", 81 | ".zip", 82 | ".tar", 83 | ".cbt", 84 | ] 85 | 86 | SUPPORTED_FILES: list[str] = ( 87 | IMAGE_FILE_FORMATS 88 | + COMPACT_FILE_FORMATS 89 | + [ 90 | ".pdf", 91 | ] 92 | ) 93 | 94 | CONFIG_FOLDER: str = ( 95 | "" 96 | if DEBUG 97 | else os.path.join( 98 | Path(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.ConfigLocation)), 99 | APP_NAME, 100 | ) 101 | ) 102 | 103 | 104 | # config file location 105 | CONFIG_FILE: str = os.path.join( 106 | CONFIG_FOLDER, 107 | f"{CONFIG_FILE_NAME}", 108 | ) 109 | 110 | # database file location 111 | DATABASE_FILE: str = os.path.join( 112 | CONFIG_FOLDER, 113 | f"{DATABASE_FILE_NAME}", 114 | ) 115 | 116 | 117 | class Language(enum.Enum): 118 | """Language enumeration""" 119 | 120 | ENGLISH = "en" 121 | PORTUGUESE_BR = "pt_BR" 122 | AUTO = "Auto" 123 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for considering contributing! This document outlines the various ways you can contribute to this project and how to get started. 4 | 5 | ## Bug Reports and Feature Requests 6 | 7 | ### Found a Bug? 8 | 9 | Before reporting an issue, please perform [a quick search](https://github.com/mstuttgart/pynocchio/issues) to check if it has already been reported. If it exists, feel free to comment on the existing issue. Otherwise, open [a new GitHub issue](https://github.com/mstuttgart/pynocchio/issues). 10 | 11 | When reporting a bug, ensure you include: 12 | 13 | - A clear and concise title. 14 | - A detailed description with relevant information. 15 | - Steps to reproduce the issue and the expected behavior. 16 | - If possible, a code sample or an executable test case demonstrating the issue. 17 | 18 | ### Have a Suggestion for an Enhancement or New Feature? 19 | 20 | We use GitHub issues to track feature requests. Before creating a feature request: 21 | 22 | - Ensure you have a clear idea of the enhancement. If unsure, consider discussing it first in a GitHub issue. 23 | - Check the documentation to confirm the feature does not already exist. 24 | - Perform [a quick search](https://github.com/mstuttgart/pynocchio/issues) to see if the feature has already been suggested. 25 | 26 | When submitting a feature request, please: 27 | 28 | - Provide a clear and descriptive title. 29 | - Explain why the enhancement would be useful, possibly referencing similar features in other libraries. 30 | - Include code examples to demonstrate how the feature would be used. 31 | 32 | Contributions are always welcome and greatly appreciated! 33 | 34 | ## Contributing Fixes and New Features 35 | 36 | When contributing fixes or new features, start by forking or branching from the [main branch](https://github.com/mstuttgart/pynocchio/tree/main) to ensure you work on the latest code and minimize merge conflicts. 37 | 38 | All contributed [PRs](https://github.com/mstuttgart/pynocchio/pulls) must include valid test coverage to be considered for merging. If you need help with writing tests, don't hesitate to ask. 39 | 40 | Thank you for your support! 41 | 42 | ## Running Tests 43 | 44 | To set up the development environment and install all required dependencies, run: 45 | 46 | ```shell 47 | $ virtualenv -p python3 .venv 48 | $ source .venv/bin/activate 49 | $ make setup 50 | ``` 51 | 52 | The project provides automated style checks, tests, and coverage reports: 53 | 54 | ```shell 55 | $ make check 56 | ``` 57 | 58 | You can also run them individually: 59 | 60 | ```shell 61 | $ make pre-commit 62 | $ make test 63 | ``` 64 | 65 | To retrieve uncovered lines, use: 66 | 67 | ```shell 68 | $ make coverage 69 | ``` 70 | 71 | To run specific tests, use the `pytest` command: 72 | 73 | ```shell 74 | $ pytest tests/test_page.py 75 | ``` 76 | 77 | For more granular testing: 78 | 79 | ```shell 80 | $ pytest tests/test_page.py::test_create_page 81 | ``` 82 | 83 | To run tests against the real API, create a `.env` file in the project's root directory with the following content: 84 | 85 | ```ini 86 | SKIP_REAL_TEST=False 87 | ``` 88 | 89 | This will enable tests that interact with the live API. Ensure you have the necessary permissions and understand the implications of running tests against the real API. 90 | 91 | ## Code Style 92 | 93 | pynocchio uses a collection of tools to maintain consistent code style. These tools are orchestrated using [pre-commit](https://pre-commit.com/), which can be installed locally to check your changes before opening a PR. The CI process will also run these checks before merging. 94 | 95 | To run pre-commit checks locally: 96 | 97 | ```shell 98 | $ make pre-commit 99 | ``` 100 | 101 | The full list of formatting requirements is specified in the [`.pre-commit-config.yaml`](https://github.com/mstuttgart/pynocchio/blob/main/.pre-commit-config.yaml) file located in the project's root directory. 102 | -------------------------------------------------------------------------------- /tests/test_comic_loader_rar.py: -------------------------------------------------------------------------------- 1 | import random 2 | from unittest import mock 3 | 4 | import pytest 5 | import rarfile # noqa: F401 6 | 7 | from src.models.comic_loader_rar import ComicRarLoader, is_rarfile 8 | from src.models.page import Page 9 | 10 | 11 | class MockRarFile: 12 | """Mock class for rarfile.RarFile.""" 13 | 14 | def __init__(self, filenames): 15 | self.filenames = filenames 16 | self.files = {} 17 | 18 | def write(self, filename, data=b""): 19 | self.files[filename] = data 20 | 21 | def namelist(self): 22 | return list(self.files.keys()) 23 | 24 | def read(self, filename): 25 | if filename in self.files: 26 | return self.files[filename] 27 | raise rarfile.BadRarFile(f"File {filename} not found in archive.") 28 | 29 | def __enter__(self): 30 | return self 31 | 32 | def __exit__(self, exc_type, exc_val, exc_tb): 33 | pass 34 | 35 | 36 | @pytest.fixture 37 | def setup_loader(): 38 | loader = ComicRarLoader("dummy_file") 39 | mock_rar_file = MockRarFile([]) 40 | 41 | valid_image_files = [f"image_{i}.jpg" for i in range(5)] 42 | invalid_files = [ 43 | "text_file.txt", 44 | "document.pdf", 45 | ] 46 | all_files = valid_image_files + invalid_files 47 | 48 | random.shuffle(all_files) 49 | 50 | for file in all_files: 51 | mock_rar_file.write(file, data=b"mock_data") 52 | 53 | return loader, mock_rar_file, valid_image_files, invalid_files 54 | 55 | 56 | def test_is_rarfile(): 57 | """Test the is_rarfile function.""" 58 | with mock.patch("rarfile.is_rarfile", return_value=True): 59 | assert is_rarfile("test.rar") 60 | 61 | with mock.patch("rarfile.is_rarfile", return_value=False): 62 | assert not is_rarfile("test.rar") 63 | 64 | 65 | def test_load_valid_rar_file(setup_loader): 66 | """Test loading a valid RAR file with supported image formats.""" 67 | loader, mock_rar_file, valid_image_files, _ = setup_loader 68 | 69 | with ( 70 | mock.patch("rarfile.is_rarfile", return_value=True), 71 | mock.patch("rarfile.RarFile", return_value=mock_rar_file), 72 | ): 73 | loader.load("mock_comic.rar") 74 | 75 | data = loader.getData() 76 | assert len(data) == len(valid_image_files) 77 | 78 | sorted_files = sorted(valid_image_files) 79 | 80 | for idx, page in enumerate(data, start=1): 81 | assert isinstance(page, Page) 82 | assert page.getTitle() == sorted_files[idx - 1] 83 | assert page.getNumber() == idx + 1 84 | assert page.getData() == b"mock_data" 85 | 86 | 87 | def test_load_invalid_data_rar_file(setup_loader): 88 | """Test loading a RAR file with no supported image formats.""" 89 | loader, _, _, invalid_files = setup_loader 90 | 91 | mock_rar_file = MockRarFile(invalid_files) 92 | 93 | with ( 94 | mock.patch("rarfile.is_rarfile", return_value=True), 95 | mock.patch("rarfile.RarFile", return_value=mock_rar_file), 96 | ): 97 | with pytest.raises(ValueError, match="No valid data found in the RAR file."): 98 | loader.load("mock_comic.rar") 99 | 100 | 101 | def test_load_non_rar_file(setup_loader): 102 | """Test loading a file that is not a valid RAR file.""" 103 | loader, _, _, _ = setup_loader 104 | 105 | with ( 106 | mock.patch("rarfile.is_rarfile", return_value=True), 107 | mock.patch("rarfile.RarFile", side_effect=rarfile.Error), 108 | ): 109 | with pytest.raises(ValueError, match="Failed to load RAR file"): 110 | loader.load("not_a_rar_file.rar") 111 | 112 | 113 | def test_load_invalid_file_type(setup_loader): 114 | """Test loading a file that is not recognized as a RAR file.""" 115 | loader, _, _, _ = setup_loader 116 | 117 | with mock.patch("rarfile.is_rarfile", return_value=False): 118 | with pytest.raises(TypeError, match="is not a valid RAR file"): 119 | loader.load("invalid_file.txt") 120 | -------------------------------------------------------------------------------- /tests/test_comic_loader_zip.py: -------------------------------------------------------------------------------- 1 | import random 2 | import zipfile # noqa: F401 3 | from unittest import mock 4 | 5 | import pytest 6 | 7 | from src.models.comic_loader_zip import ComicZipLoader, is_zipfile 8 | from src.models.page import Page 9 | 10 | 11 | class MockZipFile: 12 | """Mock class for zipfile.ZipFile.""" 13 | 14 | def __init__(self, filenames): 15 | self.filenames = filenames 16 | self.files = {} 17 | 18 | def write(self, filename, data=b""): 19 | self.files[filename] = data 20 | 21 | def namelist(self): 22 | return list(self.files.keys()) 23 | 24 | def read(self, filename): 25 | if filename in self.files: 26 | return self.files[filename] 27 | raise zipfile.BadZipFile(f"File {filename} not found in archive.") 28 | 29 | def __enter__(self): 30 | return self 31 | 32 | def __exit__(self, exc_type, exc_val, exc_tb): 33 | pass 34 | 35 | 36 | @pytest.fixture 37 | def setup_loader(): 38 | loader = ComicZipLoader("dummy_file") 39 | mock_zip_file = MockZipFile([]) 40 | 41 | valid_image_files = [f"image_{i}.jpg" for i in range(5)] 42 | invalid_files = ["text_file.txt", "document.pdf"] 43 | all_files = valid_image_files + invalid_files 44 | 45 | random.shuffle(all_files) 46 | 47 | for file in all_files: 48 | mock_zip_file.write(file, data=b"mock_data") 49 | 50 | return loader, mock_zip_file, valid_image_files, invalid_files 51 | 52 | 53 | def test_is_zipfile(): 54 | """Test the is_zipfile function.""" 55 | with mock.patch("zipfile.is_zipfile", return_value=True): 56 | assert is_zipfile("test.zip") 57 | 58 | with mock.patch("zipfile.is_zipfile", return_value=False): 59 | assert not is_zipfile("test.zip") 60 | 61 | 62 | @mock.patch("zipfile.is_zipfile") 63 | @mock.patch("zipfile.ZipFile") 64 | def test_load_valid_zip_file(mock_zipfile, mock_is_zipfile, setup_loader): 65 | """Test loading a valid ZIP file with supported image formats.""" 66 | loader, mock_zip_file, valid_image_files, _ = setup_loader 67 | 68 | mock_is_zipfile.return_value = True 69 | mock_zipfile.return_value = mock_zip_file 70 | 71 | loader.load("mock_comic.zip") 72 | 73 | data = loader.getData() 74 | assert len(data) == len(valid_image_files) 75 | 76 | sorted_files = sorted(valid_image_files) 77 | 78 | for idx, page in enumerate(data, start=1): 79 | assert isinstance(page, Page) 80 | assert page.getTitle() == sorted_files[idx - 1] 81 | assert page.getNumber() == idx + 1 82 | assert page.getData() == b"mock_data" 83 | 84 | 85 | @mock.patch("zipfile.is_zipfile") 86 | @mock.patch("zipfile.ZipFile") 87 | def test_load_invalid_data_zip_file(mock_zipfile, mock_is_zipfile, setup_loader): 88 | """Test loading a ZIP file with no supported image formats.""" 89 | loader, _, _, invalid_files = setup_loader 90 | 91 | mock_is_zipfile.return_value = True 92 | mock_zip_file = MockZipFile(invalid_files) 93 | mock_zipfile.return_value = mock_zip_file 94 | 95 | with pytest.raises(ValueError, match="No valid data found in the ZIP file."): 96 | loader.load("mock_comic.zip") 97 | 98 | 99 | @mock.patch("zipfile.is_zipfile") 100 | @mock.patch("zipfile.ZipFile") 101 | def test_load_non_zip_file(mock_zipfile, mock_is_zipfile, setup_loader): 102 | """Test loading a file that is not a valid ZIP file.""" 103 | loader, _, _, _ = setup_loader 104 | 105 | mock_is_zipfile.return_value = True 106 | mock_zipfile.side_effect = zipfile.BadZipFile 107 | 108 | with pytest.raises(ValueError, match="Failed to load ZIP file"): 109 | loader.load("not_a_zip_file.zip") 110 | 111 | 112 | @mock.patch("zipfile.is_zipfile", return_value=False) 113 | def test_load_invalid_file_type(mock_is_zipfile, setup_loader): 114 | """Test loading a file that is not recognized as a ZIP file.""" 115 | loader, _, _, _ = setup_loader 116 | 117 | with pytest.raises(TypeError, match="is not a valid ZIP file"): 118 | loader.load("invalid_file.txt") 119 | -------------------------------------------------------------------------------- /tests/test_comic_loader_tar.py: -------------------------------------------------------------------------------- 1 | import random 2 | import tarfile 3 | from unittest import mock 4 | 5 | import pytest 6 | 7 | from src.models.comic_loader_tar import ComicTarLoader, is_tarfile 8 | from src.models.page import Page 9 | 10 | 11 | class MockTarFile: 12 | """Mock class for TarFile.""" 13 | 14 | def __init__(self, filenames): 15 | self.filenames = filenames 16 | self.files = {} 17 | 18 | def write(self, filename, data=b""): 19 | self.files[filename] = data 20 | 21 | def namelist(self): 22 | return list(self.files.keys()) 23 | 24 | def read(self, filename): 25 | if filename in self.files: 26 | return self.files[filename] 27 | raise tarfile.ReadError(f"File {filename} not found in archive.") 28 | 29 | def __enter__(self): 30 | return self 31 | 32 | def __exit__(self, exc_type, exc_val, exc_tb): 33 | pass 34 | 35 | 36 | @pytest.fixture 37 | def setup_loader(): 38 | loader = ComicTarLoader("dummy_file") 39 | mock_tar_file = MockTarFile([]) 40 | 41 | valid_image_files = [f"image_{i}.jpg" for i in range(5)] 42 | invalid_files = ["text_file.txt", "document.pdf"] 43 | all_files = valid_image_files + invalid_files 44 | 45 | random.shuffle(all_files) 46 | 47 | for file in all_files: 48 | mock_tar_file.write(file, data=b"mock_data") 49 | 50 | return loader, mock_tar_file, valid_image_files, invalid_files 51 | 52 | 53 | def test_is_tarfile(): 54 | """Test the is_tarfile function.""" 55 | with mock.patch("tarfile.is_tarfile", return_value=True): 56 | assert is_tarfile("test.tar") 57 | 58 | with mock.patch("tarfile.is_tarfile", return_value=False): 59 | assert not is_tarfile("test.tar") 60 | 61 | 62 | @mock.patch("tarfile.is_tarfile") 63 | @mock.patch("src.models.comic_loader_tar.TarFile") 64 | def test_load_valid_tar_file(mock_tarfile, mock_is_tarfile, setup_loader): 65 | """Test loading a valid TAR file with supported image formats.""" 66 | loader, mock_tar_file, valid_image_files, _ = setup_loader 67 | 68 | mock_is_tarfile.return_value = True 69 | mock_tarfile.return_value = mock_tar_file 70 | 71 | loader.load("mock_comic.tar") 72 | 73 | data = loader.getData() 74 | assert len(data) == len(valid_image_files) 75 | 76 | sorted_files = sorted(valid_image_files) 77 | 78 | for idx, page in enumerate(data, start=1): 79 | assert isinstance(page, Page) 80 | assert page.getTitle() == sorted_files[idx - 1] 81 | assert page.getNumber() == idx + 1 82 | assert page.getData() == b"mock_data" 83 | 84 | 85 | @mock.patch("tarfile.is_tarfile") 86 | @mock.patch("src.models.comic_loader_tar.TarFile") 87 | def test_load_invalid_data_tar_file(mock_tarfile, mock_is_tarfile, setup_loader): 88 | """Test loading a TAR file with no supported image formats.""" 89 | loader, _, _, invalid_files = setup_loader 90 | 91 | mock_is_tarfile.return_value = True 92 | mock_tarfile.return_value = MockTarFile(invalid_files) 93 | 94 | with pytest.raises(ValueError, match="No valid data found in the TAR file."): 95 | loader.load("mock_comic.tar") 96 | 97 | 98 | @mock.patch("tarfile.is_tarfile") 99 | @mock.patch("src.models.comic_loader_tar.TarFile") 100 | def test_load_non_tar_file(mock_tarfile, mock_is_tarfile, setup_loader): 101 | """Test loading a file that is not a valid TAR file.""" 102 | loader, _, _, _ = setup_loader 103 | 104 | mock_is_tarfile.return_value = True 105 | mock_tarfile.side_effect = tarfile.ReadError 106 | 107 | with pytest.raises(ValueError, match="Failed to load TAR file"): 108 | loader.load("not_a_tar_file.tar") 109 | 110 | 111 | @mock.patch("tarfile.is_tarfile") 112 | def test_load_invalid_file_type(mock_is_tarfile, setup_loader): 113 | """Test loading a file that is not recognized as a TAR file.""" 114 | loader, _, _, _ = setup_loader 115 | 116 | mock_is_tarfile.return_value = False 117 | 118 | with pytest.raises(TypeError, match="is not a valid TAR file"): 119 | loader.load("invalid_file.txt") 120 | -------------------------------------------------------------------------------- /src/widgets/qscroll_area_viewer.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtCore import Signal 2 | from PySide6.QtGui import QColor, QCursor, QResizeEvent, Qt 3 | from PySide6.QtWidgets import QScrollArea, QWidget 4 | 5 | 6 | class QScrollAreaViewer(QScrollArea): 7 | """ 8 | A custom QScrollArea with additional functionality for mouse dragging and resizing. 9 | """ 10 | 11 | resizedSignal = Signal() 12 | 13 | def __init__(self, parent: QWidget) -> None: 14 | """ 15 | Initializes the QScrollAreaViewer. 16 | 17 | Args: 18 | parent (QWidget, optional): The parent widget. Defaults to None. 19 | """ 20 | super().__init__(parent) 21 | 22 | self._dragMouse: bool = False 23 | self._dragPosition: dict[str, int] = { 24 | "x": 0, 25 | "y": 0, 26 | } 27 | 28 | self._cursor: QCursor = QCursor(Qt.CursorShape.OpenHandCursor) 29 | self.setCursor(self._cursor) 30 | 31 | self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) 32 | self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) 33 | 34 | def resetScrollPosition(self) -> None: 35 | """ 36 | Resets the vertical scroll bar position to the top. 37 | """ 38 | self.verticalScrollBar().setValue(0) 39 | 40 | def changeBackgroundColor(self, color: QColor) -> None: 41 | """ 42 | Changes the background color of the widget. 43 | 44 | Args: 45 | color (QColor): The new background color. 46 | """ 47 | style = "QWidget { background-color: %s }" % color.name() 48 | self.setStyleSheet(style) 49 | 50 | def mousePressEvent(self, *args, **kwargs) -> None: 51 | """ 52 | Handles the mouse press event to enable dragging. 53 | 54 | Args: 55 | *args: Variable length argument list. 56 | **kwargs: Arbitrary keyword arguments. 57 | """ 58 | self._dragMouse = True 59 | self._dragPosition["x"] = args[0].x() 60 | self._dragPosition["y"] = args[0].y() 61 | self._cursor = QCursor(Qt.CursorShape.ClosedHandCursor) 62 | self.setCursor(self._cursor) 63 | 64 | super().mousePressEvent(*args, **kwargs) 65 | 66 | def mouseReleaseEvent(self, *args, **kwargs) -> None: 67 | """ 68 | Handles the mouse release event to disable dragging. 69 | 70 | Args: 71 | *args: Variable length argument list. 72 | **kwargs: Arbitrary keyword arguments. 73 | """ 74 | self._dragMouse = False 75 | self._cursor = QCursor(Qt.CursorShape.OpenHandCursor) 76 | self.setCursor(self._cursor) 77 | 78 | super().mouseReleaseEvent(*args, **kwargs) 79 | 80 | def mouseMoveEvent(self, *args, **kwargs) -> None: 81 | """ 82 | Handles the mouse move event to implement dragging behavior. 83 | 84 | Args: 85 | *args: Variable length argument list. 86 | **kwargs: Arbitrary keyword arguments. 87 | """ 88 | if self._dragMouse: 89 | pos = args[0] 90 | 91 | scroll_position = { 92 | "x": self.horizontalScrollBar().sliderPosition(), 93 | "y": self.verticalScrollBar().sliderPosition(), 94 | } 95 | 96 | new_x = scroll_position["x"] + self._dragPosition["x"] - pos.x() 97 | new_y = scroll_position["y"] + self._dragPosition["y"] - pos.y() 98 | 99 | self.horizontalScrollBar().setSliderPosition(new_x) 100 | self.verticalScrollBar().setSliderPosition(new_y) 101 | 102 | self._dragPosition["x"] = pos.x() 103 | self._dragPosition["y"] = pos.y() 104 | 105 | super().mouseMoveEvent(*args, **kwargs) 106 | 107 | def resizeEvent(self, event: QResizeEvent) -> None: 108 | """ 109 | Handles the resize event and emits the resized signal. 110 | 111 | Args: 112 | event (QResizeEvent): The resize event. 113 | """ 114 | super().resizeEvent(event) 115 | self.resizedSignal.emit() 116 | -------------------------------------------------------------------------------- /src/models/comic_handler.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtGui import QPixmap 2 | 3 | from src.models.comic import Comic 4 | from src.models.page import Page 5 | 6 | 7 | class ComicHandler: 8 | """Base class for handling comic pages.""" 9 | 10 | def __init__(self, comic: Comic, index: int = 0): 11 | """ 12 | Initialize the ComicHandler. 13 | 14 | Args: 15 | comic (Comic): The comic object containing pages. 16 | index (int, optional): The starting page index. Defaults to 0. 17 | """ 18 | self._comic: Comic = comic 19 | self._currentPageIndex: int = index 20 | self._currentPageImage: QPixmap = QPixmap() 21 | 22 | def getComic(self) -> Comic: 23 | """ 24 | Get the comic object. 25 | 26 | Returns: 27 | Comic: The comic object. 28 | """ 29 | return self._comic 30 | 31 | def setComic(self, comic: Comic) -> None: 32 | """ 33 | Set the comic object. 34 | 35 | Args: 36 | comic (Comic): The new comic object. 37 | """ 38 | self._comic = comic 39 | 40 | def getCurrentPageCount(self) -> int: 41 | """ 42 | Get the total number of pages in the comic. 43 | 44 | Returns: 45 | int: The total number of pages. 46 | """ 47 | return len(self._comic.getPages()) 48 | 49 | def getCurrentPageIndex(self) -> int: 50 | """ 51 | Get the current page index. 52 | 53 | Returns: 54 | int: The current page index. 55 | """ 56 | return self._currentPageIndex 57 | 58 | def getCurrentPageNumber(self) -> int: 59 | """ 60 | Get the current page number. 61 | 62 | Returns: 63 | int: The current page number. 64 | """ 65 | return self._currentPageIndex + 1 66 | 67 | def setCurrentPageIndex(self, idx: int): 68 | """ 69 | Set the current page index. 70 | 71 | Args: 72 | idx (int): The new page index. 73 | """ 74 | if 0 <= idx < self._comic.getPageCount(): 75 | self._currentPageIndex = idx 76 | 77 | def getCurrentPage(self) -> Page: 78 | """ 79 | Get the current page. 80 | 81 | Returns: 82 | Page: The current page object. 83 | """ 84 | return self._comic.getPages()[self._currentPageIndex] 85 | 86 | def goNextPage(self) -> None: 87 | """ 88 | Go to the next page. 89 | 90 | Raises: 91 | NotImplementedError: Must be implemented in a subclass. 92 | """ 93 | raise NotImplementedError("Must subclass me!") 94 | 95 | def goPreviousPage(self) -> None: 96 | """ 97 | Go to the previous page. 98 | 99 | Raises: 100 | NotImplementedError: Must be implemented in a subclass. 101 | """ 102 | raise NotImplementedError("Must subclass me!") 103 | 104 | def goFirstPage(self) -> None: 105 | """ 106 | Go to the first page. 107 | """ 108 | self.setCurrentPageIndex(0) 109 | 110 | def goLastPage(self) -> None: 111 | """ 112 | Go to the last page. 113 | """ 114 | self.setCurrentPageIndex(len(self._comic.getPages()) - 1) 115 | 116 | def getCurrentPageImage(self) -> QPixmap: 117 | """ 118 | Get the image of the current page. 119 | 120 | Raises: 121 | NotImplementedError: Must be implemented in a subclass. 122 | """ 123 | raise NotImplementedError("Must subclass me!") 124 | 125 | def isLastPage(self) -> bool: 126 | """ 127 | Check if the current page is the last page. 128 | 129 | Returns: 130 | bool: True if it's the last page, False otherwise. 131 | """ 132 | return self._currentPageIndex == len(self._comic.getPages()) - 1 133 | 134 | def isFirstPage(self) -> bool: 135 | """ 136 | Check if the current page is the first page. 137 | 138 | Returns: 139 | bool: True if it's the first page, False otherwise. 140 | """ 141 | return self._currentPageIndex == 0 142 | -------------------------------------------------------------------------------- /src/models/comic_loader.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import abstractmethod 3 | 4 | from PySide6.QtCore import QObject, QRunnable, Signal, Slot 5 | 6 | from src.models.constants import LOGGING_VERBOSITY 7 | from src.models.page import Page 8 | 9 | logger = logging.getLogger(__name__) 10 | logger.setLevel(LOGGING_VERBOSITY) 11 | 12 | 13 | class LoaderSignals(QObject): 14 | """ 15 | Signals emitted by the LoaderSignals class to communicate the state of a loading process. 16 | 17 | Signals: 18 | loadProgressSignal (Signal[int]): 19 | Emitted to indicate the current progress of loading, represented as an integer percentage. 20 | 21 | startProgressSignal (Signal[int]): 22 | Emitted to indicate the start of a process, with an integer parameter for initialization. 23 | 24 | doneProgressSignal (Signal[str, object]): 25 | Emitted when a process is completed, carrying the filename as a string and the loaded data as an object. 26 | 27 | finishProgressSignal (Signal): 28 | Emitted to indicate the completion of all processes, with no additional data. 29 | 30 | errorLoadSignal (Signal[str]): 31 | Emitted when an error occurs during the loading process, carrying the error message as a string. 32 | """ 33 | 34 | loadProgressSignal = Signal(int) 35 | startProgressSignal = Signal(int) 36 | doneProgressSignal = Signal(str, object) 37 | finishProgressSignal = Signal() 38 | errorLoadSignal = Signal(str) 39 | 40 | 41 | class ComicLoader(QRunnable): 42 | """ 43 | Abstract class for loading comic files. 44 | 45 | This class serves as a base for different comic file loaders, 46 | such as ZIP, RAR, and TAR loaders. 47 | """ 48 | 49 | def __init__(self, filename: str) -> None: 50 | """ 51 | Initializes the ComicLoader instance. 52 | 53 | Args: 54 | filename (str): The path to the comic file. 55 | """ 56 | super().__init__() 57 | self._data: list[Page] = [] 58 | self._signals = LoaderSignals() 59 | self._filename: str = filename 60 | 61 | @Slot() 62 | def run(self) -> None: 63 | """ 64 | Executes the `run` method to load a file and handle progress signals. 65 | 66 | This method attempts to load a file using the `load` method. If an exception 67 | occurs during the loading process, it logs the error and emits an `errorLoadSignal` 68 | with the error message. Upon successful loading, it emits a `doneProgressSignal` 69 | with the filename and loaded data. Regardless of success or failure, it emits a 70 | `finishProgressSignal` to indicate the completion of the process. 71 | """ 72 | try: 73 | self.load(self._filename) 74 | 75 | except ValueError as exc: 76 | logger.exception(f"ValueError while loading {self._filename}: {exc}") 77 | self._signals.errorLoadSignal.emit(str(exc)) 78 | 79 | except Exception as exc: 80 | logger.exception(f"Unexpected error while loading {self._filename}: {exc}") 81 | self._signals.errorLoadSignal.emit(str(exc)) 82 | 83 | else: 84 | self._signals.doneProgressSignal.emit(self._filename, self._data) 85 | 86 | finally: 87 | self._signals.finishProgressSignal.emit() 88 | 89 | @abstractmethod 90 | def load(self, filename: str) -> None: 91 | """ 92 | Abstract method to load a comic file. 93 | 94 | Args: 95 | filename (str): The path to the comic file. 96 | 97 | Raises: 98 | NotImplementedError: If the method is not implemented in a subclass. 99 | """ 100 | raise NotImplementedError("Subclasses must implement this method.") 101 | 102 | def setData(self, data: list[Page]) -> None: 103 | """ 104 | Sets the data for the comic loader. 105 | 106 | Args: 107 | data (List[Page]): The data to be set. 108 | """ 109 | self._data = data 110 | 111 | def getData(self) -> list[Page]: 112 | """ 113 | Gets the data stored in the comic loader. 114 | 115 | Returns: 116 | List[Page]: The stored data. 117 | """ 118 | return self._data 119 | 120 | def getSignals(self) -> LoaderSignals: 121 | """ 122 | Gets the signals object for the comic loader. 123 | 124 | Returns: 125 | LoaderSignals: The signals object. 126 | """ 127 | return self._signals 128 | -------------------------------------------------------------------------------- /src/models/comic_loader_pdf.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import tempfile 3 | import threading 4 | 5 | from pdf2image import convert_from_path 6 | from pdf2image.exceptions import ( 7 | PDFInfoNotInstalledError, 8 | PDFPageCountError, 9 | PDFSyntaxError, 10 | ) 11 | 12 | from src.models.comic_loader import ComicLoader 13 | from src.models.constants import LOGGING_VERBOSITY 14 | from src.models.page import Page 15 | 16 | logger = logging.getLogger(__name__) 17 | logger.setLevel(LOGGING_VERBOSITY) 18 | 19 | 20 | class ComicPdfLoader(ComicLoader): 21 | """ 22 | Comic PDF file loader class. 23 | This class is responsible for loading PDF files and creating Page 24 | objects with them. It inherits from the ComicLoader class and 25 | implements the load method to read PDF files. 26 | """ 27 | 28 | def load(self, filename: str) -> None: 29 | """ 30 | This method reads the contents of a PDF file, processes each file within the archive, 31 | and creates Page objects for valid image files. The loading process is performed 32 | using multiple threads to improve performance. 33 | 34 | Raises: 35 | ValueError: If the PDF file cannot be read, contains no valid pages, or if an error 36 | occurs during the loading process. 37 | 38 | Notes: 39 | - The method emits progress signals during the loading process: 40 | `startProgressSignal` is emitted with the total number of files to process, 41 | and `loadProgressSignal` is emitted after each file is processed. 42 | - Only files with extensions matching `IMAGE_FILE_FORMATS` are processed as pages. 43 | - Pages are sorted by their page number after loading. 44 | """ 45 | 46 | logger.info("Attempting to load file: %s", filename) 47 | 48 | try: 49 | with tempfile.TemporaryDirectory() as path: 50 | pages = convert_from_path( 51 | filename, output_folder=path, thread_count=4, dpi=300, use_cropbox=True 52 | ) 53 | 54 | logger.debug("PDF file loaded successfully.") 55 | logger.debug("Number of pages in PDF: %d", len(pages)) 56 | 57 | # # Emit the start progress signal with the number of files 58 | self.getSignals().startProgressSignal.emit(len(pages)) 59 | 60 | def readPdfFileThread(page, number) -> None: 61 | """Thread function to read a single PDF page and convert it to a QImage. 62 | 63 | Args: 64 | page (pymupdf.Page): The PDF page object to process. 65 | """ 66 | 67 | try: 68 | number = number + 1 69 | pixmap = page 70 | self._data.append(Page(pixmap, f"page {number}", number)) 71 | 72 | except Exception as exc: 73 | logger.error("Other error reading PDF file: %s" % exc) 74 | 75 | # Create a list to hold the threads 76 | threads = [] 77 | 78 | # Start threads to read each file in the PDF archive 79 | for index, page in enumerate(pages): 80 | logger.debug("Processing page: %s", index) 81 | 82 | thread = threading.Thread( 83 | target=readPdfFileThread, 84 | args=(page, index), 85 | ) 86 | 87 | threads.append(thread) 88 | thread.start() 89 | thread.join() 90 | self.getSignals().loadProgressSignal.emit(index) 91 | 92 | # Sort the pages by their number 93 | self._data.sort(key=lambda x: x.getNumber()) 94 | 95 | except PDFInfoNotInstalledError as exc: 96 | logger.error("PDFInfoNotInstalledError: %s", exc) 97 | raise ValueError("PDFInfoNotInstalledError: %s" % exc) from exc 98 | 99 | except PDFPageCountError as exc: 100 | logger.error("PDFPageCountError: %s", exc) 101 | raise ValueError("PDFPageCountError: %s" % exc) from exc 102 | 103 | except PDFSyntaxError as exc: 104 | logger.error("PDFSyntaxError: %s", exc) 105 | raise ValueError("PDFSyntaxError: %s" % exc) from exc 106 | 107 | if not self._data: 108 | logger.error("No valid data found in the PDF file: %s", filename) 109 | raise ValueError("No valid data found in the PDF file.") 110 | 111 | logger.info("Successfully loaded %d pages from %s", len(self._data), filename) 112 | -------------------------------------------------------------------------------- /src/models/comic_loader_rar.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | 4 | import rarfile 5 | 6 | from src.models.comic_loader import ComicLoader 7 | from src.models.constants import IMAGE_FILE_FORMATS, LOGGING_VERBOSITY 8 | from src.models.page import Page 9 | from src.models.utils import getFileExtension 10 | 11 | logger = logging.getLogger(__name__) 12 | logger.setLevel(LOGGING_VERBOSITY) 13 | 14 | 15 | def is_rarfile(filename): 16 | """ 17 | Verify if file is a rar file. 18 | 19 | Args: 20 | filename (str): Name of the file. 21 | 22 | Returns: 23 | bool: True if the file is a rar file, otherwise False. 24 | """ 25 | return rarfile.is_rarfile(filename) 26 | 27 | 28 | class ComicRarLoader(ComicLoader): 29 | """ 30 | Comic RAR file loader class. 31 | This class is responsible for loading RAR files and creating Page 32 | objects with them. It inherits from the ComicLoader class and 33 | implements the load method to read RAR files. 34 | """ 35 | 36 | def load(self, filename: str) -> None: 37 | """ 38 | This method reads the contents of a RAR file, processes each file within the archive, 39 | and creates Page objects for valid image files. The loading process is performed 40 | using multiple threads to improve performance. 41 | 42 | Raises: 43 | ValueError: If the RAR file cannot be read, contains no valid pages, or if an error 44 | occurs during the loading process. 45 | 46 | Notes: 47 | - The method emits progress signals during the loading process: 48 | `startProgressSignal` is emitted with the total number of files to process, 49 | and `loadProgressSignal` is emitted after each file is processed. 50 | - Only files with extensions matching `IMAGE_FILE_FORMATS` are processed as pages. 51 | - Pages are sorted by their page number after loading. 52 | """ 53 | 54 | logger.info("Attempting to load file: %s", filename) 55 | 56 | if not is_rarfile(filename): 57 | logger.error("The file %s is not a valid RAR file.", filename) 58 | raise TypeError(f"The file '{filename}' is not a valid RAR file.") 59 | 60 | try: 61 | with rarfile.RarFile(filename, "r") as rar: 62 | # Get the list of files in the RAR archive 63 | namelist: list[str] = sorted(rar.namelist()) 64 | 65 | # Emit the start progress signal with the number of files 66 | self.getSignals().startProgressSignal.emit(len(namelist) - 1) 67 | 68 | def readRarFileThread(name: str, number: int) -> None: 69 | """Thread function to read RAR file contents. 70 | Args: 71 | name (str): Name of the file in the RAR archive. 72 | number (int): Page number. 73 | """ 74 | 75 | if getFileExtension(name).lower() in IMAGE_FILE_FORMATS: 76 | logger.debug("Adding page: %s", name) 77 | 78 | try: 79 | self._data.append(Page(rar.read(name), name, number)) 80 | 81 | except rarfile.BadRarFile as exc: 82 | logger.error("Error reading RAR file '%s': %s", name, exc) 83 | 84 | except Exception as exc: 85 | logger.error("Other error reading RAR file %s: %s", name, exc) 86 | 87 | # Create a list to hold the threads 88 | threads = [] 89 | 90 | # Start threads to read each file in the RAR archive 91 | for pageNumber, name in enumerate(namelist, start=1): 92 | logger.debug("Processing file: %s", name) 93 | 94 | thread = threading.Thread(target=readRarFileThread, args=(name, pageNumber)) 95 | threads.append(thread) 96 | thread.start() 97 | 98 | # Wait for all threads to finish 99 | for index, thread in enumerate(threads): 100 | thread.join() 101 | self.getSignals().loadProgressSignal.emit(index) 102 | 103 | # Sort the pages by their number 104 | self._data.sort(key=lambda x: x.getNumber()) 105 | 106 | except rarfile.Error as exc: 107 | logger.exception("Failed to load RAR file '%s': %s", filename, exc) 108 | raise ValueError(f"Failed to load RAR file '{filename}'.") from exc 109 | 110 | if not self._data: 111 | logger.error("No valid data found in the RAR file: %s", filename) 112 | raise ValueError("No valid data found in the RAR file.") 113 | 114 | logger.info("Successfully loaded %d pages from %s", len(self._data), filename) 115 | -------------------------------------------------------------------------------- /src/models/comic_loader_zip.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | import zipfile 4 | 5 | from src.models.comic_loader import ComicLoader 6 | from src.models.constants import IMAGE_FILE_FORMATS, LOGGING_VERBOSITY 7 | from src.models.page import Page 8 | from src.models.utils import getFileExtension 9 | 10 | logger = logging.getLogger(__name__) 11 | logger.setLevel(LOGGING_VERBOSITY) 12 | 13 | 14 | def is_zipfile(filename: str) -> bool: 15 | """ 16 | Verify if a file is a ZIP file. 17 | 18 | Args: 19 | filename (str): Name of the file. 20 | 21 | Returns: 22 | bool: True if the file is a ZIP file, otherwise False. 23 | """ 24 | return zipfile.is_zipfile(filename) 25 | 26 | 27 | class ComicZipLoader(ComicLoader): 28 | """ 29 | Comic ZIP file loader class. 30 | This class is responsible for loading ZIP files and creating Page 31 | objects with them. It inherits from the ComicLoader class and 32 | implements the load method to read ZIP files. 33 | """ 34 | 35 | def load(self, filename: str) -> None: 36 | """ 37 | This method reads the contents of a ZIP file, processes each file within the archive, 38 | and creates Page objects for valid image files. The loading process is performed 39 | using multiple threads to improve performance. 40 | 41 | Raises: 42 | ValueError: If the ZIP file cannot be read, contains no valid pages, or if an error 43 | occurs during the loading process. 44 | 45 | Notes: 46 | - The method emits progress signals during the loading process: 47 | `startProgressSignal` is emitted with the total number of files to process, 48 | and `loadProgressSignal` is emitted after each file is processed. 49 | - Only files with extensions matching `IMAGE_FILE_FORMATS` are processed as pages. 50 | - Pages are sorted by their page number after loading. 51 | """ 52 | logger.info("Attempting to load file: %s", filename) 53 | 54 | if not is_zipfile(filename): 55 | logger.error("The file %s is not a valid ZIP file.", filename) 56 | raise TypeError(f"The file '{filename}' is not a valid ZIP file.") 57 | 58 | try: 59 | with zipfile.ZipFile(filename, "r") as zf: 60 | # Get the list of files in the ZIP archive 61 | namelist: list[str] = sorted(zf.namelist()) 62 | 63 | # Emit the start progress signal with the number of files 64 | self.getSignals().startProgressSignal.emit(len(namelist) - 1) 65 | 66 | def readZipFileThread(name: str, number: int) -> None: 67 | """Thread function to read ZIP file contents. 68 | This function is executed in a separate thread to 69 | read the contents of a ZIP file and create Page 70 | objects from image files. 71 | 72 | Args: 73 | name (str): Name of the file in the ZIP archive. 74 | number (int): Page number for the image. 75 | """ 76 | 77 | if getFileExtension(name).lower() in IMAGE_FILE_FORMATS: 78 | logger.debug("Adding page: %s", name) 79 | 80 | try: 81 | self._data.append(Page(zf.read(name), name, number)) 82 | 83 | logger.debug("Page %d loaded successfully.", number) 84 | 85 | except zipfile.BadZipfile as exc: 86 | logger.error("Error reading ZIP file %s: %s", name, exc) 87 | 88 | except Exception as exc: 89 | logger.error("Other error reading ZIP file %s: %s", name, exc) 90 | 91 | # Create a list to hold the threads 92 | threads = [] 93 | 94 | # Start threads to read each file in the RAR archive 95 | for pageNumber, name in enumerate(namelist, start=1): 96 | logger.debug("Processing file: %s", name) 97 | 98 | thread = threading.Thread(target=readZipFileThread, args=(name, pageNumber)) 99 | threads.append(thread) 100 | thread.start() 101 | 102 | # Wait for all threads to finish 103 | for index, thread in enumerate(threads): 104 | thread.join() 105 | self.getSignals().loadProgressSignal.emit(index) 106 | 107 | # Sort the pages by their number 108 | self._data.sort(key=lambda x: x.getNumber()) 109 | 110 | except zipfile.BadZipfile as exc: 111 | logger.exception("Failed to load ZIP file %s: %s", filename, exc) 112 | raise ValueError(f"Failed to load ZIP file {filename}: {exc}") 113 | 114 | if not self._data: 115 | logger.error("No valid data found in the ZIP file: %s", filename) 116 | raise ValueError("No valid data found in the ZIP file.") 117 | 118 | logger.info("Successfully loaded %d pages from %s", len(self._data), filename) 119 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | Pynocchio 11 | 15 | 16 | 17 | 21 | 22 | 23 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 39 | 40 | 41 |
42 |
43 |
44 |
45 |

Pynocchio

46 |

47 | Logo 53 |

54 |

A minimalist comic reader

55 |
56 | Download for Linux 57 |
58 |
59 |
60 |

61 |

  • 62 | Supports several view adjustment modes with anti-aliasing. 63 |
  • 64 |
  • 65 | Compatible with multiple image formats supported: WEBP, JPG, 66 | JPEG, PNG, GIF, BMP, PBM, PGM, PPM, XBM, XPM. 67 |
  • 68 |
  • 69 | Compatible with PFD comic files. 70 |
  • 71 | 72 |
  • 73 | Supports various comic archive formats: .ZIP, .RAR, 74 | .TAR, .CBT, .CBR, .CBZ. 75 |
  • 76 |
  • 77 | Supports multiple comic reading modes: vertical, horizontal, 78 | and page flipping. 79 |
  • 80 |
  • Minimalist design, free, and easy to use!
  • 81 | 82 | 83 | We work with world's top companies to create beautiful products & 84 | apps. 85 |

    86 | Google Design 91 |

    92 | This screenshot contains a page of the webcomic Pepper&Carrot by David Revoy, licensed under the Creative Commons Attribution 4.0 International (CC BY 4.0). 93 |

    94 |
    95 |
    96 |
    97 |
    98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /src/models/comic_loader_tar.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import tarfile 3 | import threading 4 | 5 | from src.models.comic_loader import ComicLoader 6 | from src.models.constants import IMAGE_FILE_FORMATS, LOGGING_VERBOSITY 7 | from src.models.page import Page 8 | from src.models.utils import getFileExtension 9 | 10 | logger = logging.getLogger(__name__) 11 | logger.setLevel(LOGGING_VERBOSITY) 12 | 13 | 14 | def is_tarfile(filename: str) -> bool: 15 | """ 16 | Verify if file is tar file 17 | 18 | Args: 19 | filename: name of file 20 | 21 | Returns: True if file is a tar file otherwise, False 22 | 23 | """ 24 | return tarfile.is_tarfile(filename) 25 | 26 | 27 | class TarFile(tarfile.TarFile): 28 | """ 29 | A subclass of tarfile.TarFile that provides additional utility methods 30 | for reading files and retrieving file names from a TAR archive. 31 | """ 32 | 33 | def read(self, filename: str) -> bytes: 34 | """ 35 | Read the contents of a file within the TAR archive. 36 | 37 | Args: 38 | filename (str): The name of the file to read from the TAR archive. 39 | 40 | Returns: 41 | bytes: The binary data of the specified file. 42 | 43 | Raises: 44 | KeyError: If the specified file is not found in the TAR archive. 45 | tarfile.ExtractError: If there is an error extracting the file. 46 | """ 47 | file = self.extractfile(filename) 48 | if file is None: 49 | raise KeyError(f"File '{filename}' not found in the TAR archive.") 50 | return file.read() 51 | 52 | def namelist(self) -> list[str]: 53 | """ 54 | Retrieve the list of file names contained in the TAR archive. 55 | 56 | Returns: 57 | list[str]: A list of file names present in the TAR archive. 58 | """ 59 | return self.getnames() 60 | 61 | 62 | class ComicTarLoader(ComicLoader): 63 | """ 64 | Comic TAR file loader class. 65 | This class is responsible for loading TAR files and creating Page 66 | objects with them. It inherits from the ComicLoader class and 67 | implements the load method to read TAR files. 68 | """ 69 | 70 | def load(self, filename: str) -> None: 71 | """ 72 | This method reads the contents of a TAR file, processes each file within the archive, 73 | and creates Page objects for valid image files. The loading process is performed 74 | using multiple threads to improve performance. 75 | 76 | Raises: 77 | ValueError: If the TAR file cannot be read, contains no valid pages, or if an error 78 | occurs during the loading process. 79 | 80 | Notes: 81 | - The method emits progress signals during the loading process: 82 | `startProgressSignal` is emitted with the total number of files to process, 83 | and `loadProgressSignal` is emitted after each file is processed. 84 | - Only files with extensions matching `IMAGE_FILE_FORMATS` are processed as pages. 85 | - Pages are sorted by their page number after loading. 86 | """ 87 | logger.info("Attempting to load file: %s", filename) 88 | 89 | if not is_tarfile(filename): 90 | logger.error("The file %s is not a valid TAR file.", filename) 91 | raise TypeError(f"The file '{filename}' is not a valid TAR file.") 92 | 93 | try: 94 | with TarFile(filename, "r") as tar: 95 | # Get the list of files in the TAR archive 96 | namelist: list[str] = sorted(tar.namelist()) 97 | 98 | # Emit the start progress signal with the number of files 99 | self.getSignals().startProgressSignal.emit(len(namelist) - 1) 100 | 101 | def readTarFileThread(name: str, number: int) -> None: 102 | """Thread function to read TAR file contents. 103 | Args: 104 | name (str): Name of the file in the TAR archive. 105 | number (int): Page number. 106 | """ 107 | 108 | if getFileExtension(name).lower() in IMAGE_FILE_FORMATS: 109 | logger.debug("Adding page: %s", name) 110 | 111 | try: 112 | self._data.append(Page(tar.read(name), name, number)) 113 | 114 | except tarfile.ExtractError as exc: 115 | logger.error("Error reading TAR file %s: %s", name, exc) 116 | 117 | except Exception as exc: 118 | logger.error("Other error reading TAR file %s: %s", name, exc) 119 | 120 | # Create a list to hold the threads 121 | threads = [] 122 | 123 | # Start threads to read each file in the TAR archive 124 | for pageNumber, name in enumerate(namelist, start=1): 125 | logger.debug("Processing file: %s", name) 126 | 127 | thread = threading.Thread(target=readTarFileThread, args=(name, pageNumber)) 128 | threads.append(thread) 129 | thread.start() 130 | 131 | # Wait for all threads to finish 132 | for index, thread in enumerate(threads): 133 | thread.join() 134 | self.getSignals().loadProgressSignal.emit(index) 135 | 136 | # Sort the pages by their number 137 | self._data.sort(key=lambda x: x.getNumber()) 138 | 139 | except tarfile.ReadError as exc: 140 | logger.exception("Failed to load TAR file %s: %s", filename, exc) 141 | raise ValueError(f"Failed to load TAR file {filename}: {exc}") 142 | 143 | if not self._data: 144 | logger.error("No valid data found in the TAR file: %s", filename) 145 | raise ValueError("No valid data found in the TAR file.") 146 | 147 | logger.info("Successfully loaded %d pages from %s", len(self._data), filename) 148 | -------------------------------------------------------------------------------- /i18n/en_US.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | MainWindowView 6 | 7 | 8 | 9 | Page Number 10 | Page Number 11 | 12 | 13 | 14 | Exit 15 | Exit 16 | 17 | 18 | 19 | Open File 20 | Open File 21 | 22 | 23 | 24 | About 25 | About 26 | 27 | 28 | 29 | Report a Bug 30 | Report a Bug 31 | 32 | 33 | 34 | Previous Page 35 | Previous Page 36 | 37 | 38 | 39 | Next Page 40 | Next Page 41 | 42 | 43 | 44 | First Page 45 | First Page 46 | 47 | 48 | 49 | Last Page 50 | Last Page 51 | 52 | 53 | 54 | Previous Comic 55 | Previous Comic 56 | 57 | 58 | 59 | Next Comic 60 | Next Comic 61 | 62 | 63 | 64 | Fit Vertical 65 | Fit Vertical 66 | 67 | 68 | 69 | Fit Horizontal 70 | Fit Horizontal 71 | 72 | 73 | 74 | Fit Original 75 | Fit Original 76 | 77 | 78 | 79 | Fit Page 80 | Fit Page 81 | 82 | 83 | 84 | Rotate Left 85 | Rotate Left 86 | 87 | 88 | 89 | Rotate Right 90 | Rotate Right 91 | 92 | 93 | 94 | Press Ctrl+0 to Open a Comic File 95 | Press Ctrl+0 to Open a Comic File 96 | 97 | 98 | 99 | Loading Comic... 100 | Loading Comic... 101 | 102 | 103 | 104 | Error Loading Comic 105 | Error Loading Comic 106 | 107 | 108 | 109 | Open Comic File 110 | Open Comic File 111 | 112 | 113 | 114 | All supported files (*.zip *.cbz *.rar *.cbr *.tar *.cbt);; ZIP files (*.zip *.cbz);; RAR files (*.rar *.cbr);; TAR files (*.tar *.cbt);; All files (*) 115 | All supported files (*.zip *.cbz *.rar *.cbr *.tar *.cbt);; ZIP files (*.zip *.cbz);; RAR files (*.rar *.cbr);; TAR files (*.tar *.cbt);; All files (*) 116 | 117 | 118 | 119 | of %d 120 | of %d 121 | 122 | 123 | 124 | About {APP_NAME} 125 | About {APP_NAME} 126 | 127 | 128 | 129 | <h1>{APP_NAME}</h1><h3>Version {VERSION}</h3><br/>A minimalist comic book reader. 130 | <br/><br/>GNU General Public License v3 (<a href="{LICENSE_URL}">GPLv3</a>) <br/><br/>{COPYRIGHT}<br/><br/> 131 | <h1>{APP_NAME}</h1><h3>Version {VERSION}</h3><br/>A minimalist comic book reader. 132 | <br/><br/>GNU General Public License v3 (<a href="{LICENSE_URL}">GPLv3</a>) <br/><br/>{COPYRIGHT}<br/><br/> 133 | 134 | 135 | 136 | -------------------------------------------------------------------------------- /i18n/es_ES.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | MainWindowView 6 | 7 | 8 | 9 | Page Number 10 | Número de página 11 | 12 | 13 | 14 | Exit 15 | Salir 16 | 17 | 18 | 19 | Open File 20 | Abrir archivo 21 | 22 | 23 | 24 | About 25 | Acerca de 26 | 27 | 28 | 29 | Report a Bug 30 | Reportar un error 31 | 32 | 33 | 34 | Previous Page 35 | Página anterior 36 | 37 | 38 | 39 | Next Page 40 | Página siguiente 41 | 42 | 43 | 44 | First Page 45 | Primera página 46 | 47 | 48 | 49 | Last Page 50 | Última página 51 | 52 | 53 | 54 | Previous Comic 55 | Cómic anterior 56 | 57 | 58 | 59 | Next Comic 60 | Cómic siguiente 61 | 62 | 63 | 64 | Fit Vertical 65 | Ajustar vertical 66 | 67 | 68 | 69 | Fit Horizontal 70 | Ajustar horizontal 71 | 72 | 73 | 74 | Fit Original 75 | Ajustar original 76 | 77 | 78 | 79 | Fit Page 80 | Ajustar página 81 | 82 | 83 | 84 | Rotate Left 85 | Girar a la izquierda 86 | 87 | 88 | 89 | Rotate Right 90 | Girar a la derecha 91 | 92 | 93 | 94 | Press Ctrl+0 to Open a Comic File 95 | Presiona Ctrl+0 para abrir un archivo de cómic 96 | 97 | 98 | 99 | Loading Comic... 100 | Cargando cómic... 101 | 102 | 103 | 104 | Error Loading Comic 105 | Error al cargar el cómic 106 | 107 | 108 | 109 | Open Comic File 110 | Abrir archivo de cómic 111 | 112 | 113 | 114 | All supported files (*.zip *.cbz *.rar *.cbr *.tar *.cbt);; ZIP files (*.zip *.cbz);; RAR files (*.rar *.cbr);; TAR files (*.tar *.cbt);; All files (*) 115 | Todos los archivos soportados (*.zip *.cbz *.rar *.cbr *.tar *.cbt);; Archivos ZIP (*.zip *.cbz);; Archivos RAR (*.rar *.cbr);; Archivos TAR (*.tar *.cbt);; Todos los archivos (*) 116 | 117 | 118 | 119 | of %d 120 | de %d 121 | 122 | 123 | 124 | About {APP_NAME} 125 | Acerca de {APP_NAME} 126 | 127 | 128 | 129 | <h1>{APP_NAME}</h1><h3>Version {VERSION}</h3><br/>A minimalist comic book reader. 130 | <br/><br/>GNU General Public License v3 (<a href="{LICENSE_URL}">GPLv3</a>) <br/><br/>{COPYRIGHT}<br/><br/> 131 | <h1>{APP_NAME}</h1><h3>Versión {VERSION}</h3><br/>Un lector de cómics minimalista. 132 | <br/><br/>Licencia Pública General de GNU v3 (<a href="{LICENSE_URL}">GPLv3</a>) <br/><br/>{COPYRIGHT}<br/><br/> 133 | 134 | 135 | 136 | -------------------------------------------------------------------------------- /i18n/pt_BR.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | MainWindowView 6 | 7 | 8 | 9 | Page Number 10 | Número da Página 11 | 12 | 13 | 14 | Exit 15 | Sair 16 | 17 | 18 | 19 | Open File 20 | Abrir Arquivo 21 | 22 | 23 | 24 | About 25 | Sobre 26 | 27 | 28 | 29 | Report a Bug 30 | Reportar um Bug 31 | 32 | 33 | 34 | Previous Page 35 | Página Anterior 36 | 37 | 38 | 39 | Next Page 40 | Próxima Página 41 | 42 | 43 | 44 | First Page 45 | Primeira Página 46 | 47 | 48 | 49 | Last Page 50 | Última Página 51 | 52 | 53 | 54 | Previous Comic 55 | Quadrinho Anterior 56 | 57 | 58 | 59 | Next Comic 60 | Próximo Quadrinho 61 | 62 | 63 | 64 | Fit Vertical 65 | Ajustar Vertical 66 | 67 | 68 | 69 | Fit Horizontal 70 | Ajustar Horizontal 71 | 72 | 73 | 74 | Fit Original 75 | Ajustar Original 76 | 77 | 78 | 79 | Fit Page 80 | Ajustar Página 81 | 82 | 83 | 84 | Rotate Left 85 | Girar para a Esquerda 86 | 87 | 88 | 89 | Rotate Right 90 | Girar para a Direita 91 | 92 | 93 | 94 | Press Ctrl+0 to Open a Comic File 95 | Pressione Ctrl+0 para Abrir um Arquivo de Quadrinhos 96 | 97 | 98 | 99 | Loading Comic... 100 | Carregando Quadrinho... 101 | 102 | 103 | 104 | Error Loading Comic 105 | Erro ao Carregar Quadrinho 106 | 107 | 108 | 109 | Open Comic File 110 | Abrir Arquivo de Quadrinhos 111 | 112 | 113 | 114 | All supported files (*.zip *.cbz *.rar *.cbr *.tar *.cbt);; ZIP files (*.zip *.cbz);; RAR files (*.rar *.cbr);; TAR files (*.tar *.cbt);; All files (*) 115 | Todos os arquivos suportados (*.zip *.cbz *.rar *.cbr *.tar *.cbt);; Arquivos ZIP (*.zip *.cbz);; Arquivos RAR (*.rar *.cbr);; Arquivos TAR (*.tar *.cbt);; Todos os arquivos (*) 116 | 117 | 118 | 119 | of %d 120 | de %d 121 | 122 | 123 | 124 | About {APP_NAME} 125 | Sobre {APP_NAME} 126 | 127 | 128 | 129 | <h1>{APP_NAME}</h1><h3>Version {VERSION}</h3><br/>A minimalist comic book reader. 130 | <br/><br/>GNU General Public License v3 (<a href="{LICENSE_URL}">GPLv3</a>) <br/><br/>{COPYRIGHT}<br/><br/> 131 | <h1>{APP_NAME}</h1><h3>Versão {VERSION}</h3><br/>Um leitor minimalista de quadrinhos. 132 | <br/><br/>Licença Pública Geral GNU v3 (<a href="{LICENSE_URL}">GPLv3</a>) <br/><br/>{COPYRIGHT}<br/><br/> 133 | 134 | 135 | 136 | -------------------------------------------------------------------------------- /docs/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 31 | 33 | 35 | 37 | 43 | 44 | 50 | 55 | 60 | 61 | 68 | 72 | 76 | 79 | 80 | 81 | 85 | 93 | 96 | 101 | 109 | 117 | 123 | 124 | 125 | 139 | 152 | 165 | 166 | 167 | -------------------------------------------------------------------------------- /resources/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 31 | 33 | 35 | 37 | 43 | 44 | 50 | 55 | 60 | 61 | 68 | 72 | 76 | 79 | 80 | 81 | 85 | 93 | 96 | 101 | 109 | 117 | 123 | 124 | 125 | 139 | 152 | 165 | 166 | 167 | -------------------------------------------------------------------------------- /package/usr/share/icons/hicolor/scalable/apps/pynocchio.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 31 | 33 | 35 | 37 | 43 | 44 | 50 | 55 | 60 | 61 | 68 | 72 | 76 | 79 | 80 | 81 | 85 | 93 | 96 | 101 | 109 | 117 | 123 | 124 | 125 | 139 | 152 | 165 | 166 | 167 | -------------------------------------------------------------------------------- /src/models/main_model.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from typing import Optional 4 | 5 | from PySide6.QtCore import QDir, QObject, QSettings 6 | 7 | from src.models import reading_list_model 8 | from src.models.comic import Comic 9 | from src.models.comic_handler import ComicHandler 10 | from src.models.comic_handler_single_page import ComicHandlerSinglePage 11 | from src.models.constants import COMPACT_FILE_FORMATS, LOGGING_VERBOSITY 12 | from src.models.utils import getBaseName, getDirName 13 | 14 | logger = logging.getLogger(__name__) 15 | logger.setLevel(LOGGING_VERBOSITY) 16 | 17 | 18 | class MainModel(QObject): 19 | """ 20 | Model class for the application. 21 | 22 | This class serves as the data model for the application, managing the 23 | application's state and providing methods to manipulate that state. 24 | It communicates with the controller to update the view when the model 25 | changes. 26 | """ 27 | 28 | def __init__(self) -> None: 29 | """ 30 | Initializes the MainModel. 31 | """ 32 | logger.info("Initializing MainModel") 33 | super().__init__() 34 | 35 | self._comic: Optional[Comic] = None 36 | self._comicPageHandler: Optional[ComicHandler] = None 37 | self._settings = QSettings() 38 | self._pageRotateAngle: int = 0 39 | self._currentComicPath: str = "" 40 | self._currentFitMode: str = "" 41 | self._previousComicName: Optional[str] = None 42 | self._nextComicName: Optional[str] = None 43 | 44 | self.loadData() 45 | logger.info("MainModel initialized") 46 | 47 | def setComic(self, comic: Comic) -> None: 48 | """ 49 | Sets the comic object. 50 | 51 | Args: 52 | comic (Comic): The comic object to set. 53 | """ 54 | self._comic = comic 55 | 56 | def getComic(self) -> Optional[Comic]: 57 | """ 58 | Gets the comic object. 59 | 60 | Returns: 61 | Optional[Comic]: The comic object, or None if not set. 62 | """ 63 | return self._comic 64 | 65 | def setComicHandler(self, handler: ComicHandler) -> None: 66 | """ 67 | Sets the comic handler. 68 | 69 | Args: 70 | handler (ComicHandler): The comic handler to set. 71 | """ 72 | self._comicPageHandler = handler 73 | 74 | def getComicHandler(self) -> Optional[ComicHandler]: 75 | """ 76 | Gets the comic handler. 77 | 78 | Returns: 79 | Optional[ComicHandler]: The comic handler, or None if not set. 80 | """ 81 | return self._comicPageHandler 82 | 83 | def setCurrentComicPath(self, comic_path: str) -> None: 84 | """ 85 | Sets the current comic path. 86 | 87 | Args: 88 | comic_path (str): The comic path to set. 89 | """ 90 | self._currentComicPath = comic_path 91 | 92 | def getCurrentComicPath(self) -> str: 93 | """ 94 | Gets the current comic path. 95 | 96 | Returns: 97 | str: The current comic path. 98 | """ 99 | return self._currentComicPath 100 | 101 | def setCurrentFitMode(self, fit_mode: str) -> None: 102 | """ 103 | Sets the current fit mode. 104 | 105 | Args: 106 | fit_mode (str): The fit mode to set. 107 | """ 108 | self._currentFitMode = fit_mode 109 | 110 | def getCurrentFitMode(self) -> str: 111 | """ 112 | Gets the current fit mode. 113 | 114 | Returns: 115 | str: The current fit mode. 116 | """ 117 | return self._currentFitMode 118 | 119 | def loadComic(self, comicPath: str, data: list) -> Comic: 120 | """ 121 | Loads a comic file, initializes the comic model, and updates the view. 122 | 123 | Args: 124 | comicPath (str): The path to the comic file to be loaded. 125 | data (list): The list of pages in the comic. 126 | 127 | Returns: 128 | Comic: The loaded comic object. 129 | 130 | Raises: 131 | Exception: If an error occurs during the loading process. 132 | """ 133 | if self._comic and self._comicPageHandler: 134 | self.saveReadingList() 135 | 136 | self._comic = Comic(getBaseName(comicPath), getDirName(comicPath)) 137 | self._comic.setPages(data) 138 | 139 | initialIndex = reading_list_model.getLastComicPage(comicPath) or 0 140 | self._comicPageHandler = ComicHandlerSinglePage(self._comic, initialIndex) 141 | 142 | self._previousComicName, self._nextComicName = self._getComicsInPath( 143 | self._comic.getDirectory(), self._comic.getFilename() 144 | ) 145 | 146 | logger.info("Loading %s at page %d", comicPath, (initialIndex) + 1) 147 | return self._comic 148 | 149 | def _getComicsInPath(self, path: str, filename: str) -> tuple[Optional[str], Optional[str]]: 150 | """ 151 | Gets the previous and next comic files in the specified path. 152 | 153 | Args: 154 | path (str): The directory path to search for comic files. 155 | filename (str): The current comic filename. 156 | 157 | Returns: 158 | tuple[Optional[str], Optional[str]]: A tuple containing the previous and next comic filenames. 159 | """ 160 | logger.debug("Getting comic files in path: %s", path) 161 | 162 | qDir = QDir(path) 163 | qDir.setFilter(QDir.Filter.Files | QDir.Filter.NoDotAndDotDot) 164 | qDir.setSorting(QDir.SortFlag.Name) 165 | qDir.setNameFilters([f"*{ext}" for ext in COMPACT_FILE_FORMATS]) 166 | 167 | entryList = qDir.entryList() 168 | logger.debug("Found comic files: %s", entryList) 169 | 170 | if not entryList: 171 | logger.warning("No comic files found in path: %s", path) 172 | return None, None 173 | 174 | try: 175 | currentComicIndex = entryList.index(filename) 176 | except ValueError: 177 | logger.warning("Current comic not found in path: %s", path) 178 | return None, None 179 | 180 | previousComicPath = entryList[currentComicIndex - 1] if currentComicIndex > 0 else None 181 | nextComicPath = ( 182 | entryList[currentComicIndex + 1] if currentComicIndex < len(entryList) - 1 else None 183 | ) 184 | 185 | logger.debug("Previous comic path: %s", previousComicPath) 186 | logger.debug("Next comic path: %s", nextComicPath) 187 | 188 | return previousComicPath, nextComicPath 189 | 190 | def rotatePageLeft(self) -> None: 191 | """ 192 | Rotates the current page 90 degrees to the left (counterclockwise). 193 | """ 194 | self._pageRotateAngle = (self._pageRotateAngle - 90) % 360 195 | 196 | def rotatePageRight(self) -> None: 197 | """ 198 | Rotates the current page 90 degrees to the right (clockwise). 199 | """ 200 | self._pageRotateAngle = (self._pageRotateAngle + 90) % 360 201 | 202 | def getPageRotateAngle(self) -> int: 203 | """ 204 | Gets the current rotation angle of the page. 205 | 206 | Returns: 207 | int: The current rotation angle of the page. 208 | """ 209 | return self._pageRotateAngle 210 | 211 | def getPreviousComicPath(self) -> Optional[str]: 212 | """ 213 | Gets the path to the previous comic. 214 | 215 | Returns: 216 | Optional[str]: The path to the previous comic, or None if not available. 217 | """ 218 | return ( 219 | os.path.join(self._currentComicPath, self._previousComicName) 220 | if self._previousComicName 221 | else None 222 | ) 223 | 224 | def getNextComicPath(self) -> Optional[str]: 225 | """ 226 | Gets the path to the next comic. 227 | 228 | Returns: 229 | Optional[str]: The path to the next comic, or None if not available. 230 | """ 231 | return ( 232 | os.path.join(self._currentComicPath, self._nextComicName) 233 | if self._nextComicName 234 | else None 235 | ) 236 | 237 | def loadData(self) -> None: 238 | """ 239 | Loads data from the settings manager. 240 | """ 241 | logger.info("Loading settings") 242 | self._currentComicPath = str(self._settings.value("currentComicPath", "")) 243 | self._currentFitMode = str(self._settings.value("currentFitMode", "")) 244 | logger.info("All settings loaded") 245 | 246 | def saveData(self) -> None: 247 | """ 248 | Saves data to the settings manager. 249 | """ 250 | logger.info("Saving settings") 251 | self._settings.setValue("currentComicPath", self._currentComicPath) 252 | self._settings.setValue("currentFitMode", self._currentFitMode) 253 | self.saveReadingList() 254 | logger.info("All settings saved") 255 | 256 | def saveReadingList(self) -> None: 257 | """ 258 | Saves the reading list to the database. 259 | """ 260 | logger.info("Saving reading list") 261 | if self._comic and self._comicPageHandler: 262 | reading_list_model.addEntry( 263 | self._comic.getFilename(), 264 | self._comic.getComicPath(), 265 | self._comicPageHandler.getCurrentPageIndex(), 266 | ) 267 | logger.info("Reading list saved successfully") 268 | -------------------------------------------------------------------------------- /src/controllers/main_controller.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import TYPE_CHECKING, Any, Optional, Union 3 | 4 | from PySide6.QtCore import QObject, QThreadPool, QUrl, Signal, Slot 5 | from PySide6.QtGui import QDesktopServices, QPixmap 6 | 7 | from src.models.comic_loader_factory import ComicLoaderFactory 8 | from src.models.constants import HELP_URL 9 | from src.models.main_model import MainModel 10 | 11 | if TYPE_CHECKING: 12 | from src.models.comic_loader import ComicLoader 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class MainController(QObject): 18 | """ 19 | Main controller for the application. 20 | 21 | This class serves as the intermediary between the model and the view. 22 | It handles user interactions and updates the view accordingly. 23 | """ 24 | 25 | # Signals 26 | updateCentralWidgetContentSignal = Signal(QPixmap, int) 27 | updatePageBoxSignal = Signal(int, int) 28 | updateWindowTitleSignal = Signal(str) 29 | closeMainWindowSignal = Signal() 30 | updatePageActionsSignal = Signal(bool, bool) 31 | loadProgressSignal = Signal(int) 32 | startProgressSignal = Signal(int) 33 | doneProgressSignal = Signal() 34 | errorLoadSignal = Signal(str) 35 | finishProgressSignal = Signal() 36 | 37 | def __init__(self, model: MainModel) -> None: 38 | """ 39 | Initializes the MainController. 40 | 41 | Args: 42 | model (MainModel): The model instance to be controlled. 43 | """ 44 | super().__init__() 45 | self._mainModel: MainModel = model 46 | self._loader: Union[None, ComicLoader] = None 47 | self.threadpool: Union[None, QThreadPool] = None 48 | 49 | def updateCentralWidgetContent(self) -> None: 50 | """ 51 | Updates the central widget content with the current page image and rotation angle. 52 | """ 53 | comicHandler = self._mainModel.getComicHandler() 54 | if comicHandler: 55 | self.updateCentralWidgetContentSignal.emit( 56 | comicHandler.getCurrentPageImage(), 57 | self._mainModel.getPageRotateAngle(), 58 | ) 59 | 60 | def updatePageBox(self) -> None: 61 | """ 62 | Updates the page box in the main window view with the current page number and total pages. 63 | """ 64 | comicHandler = self._mainModel.getComicHandler() 65 | if comicHandler: 66 | self.updatePageBoxSignal.emit( 67 | comicHandler.getCurrentPageIndex() + 1, comicHandler.getCurrentPageCount() 68 | ) 69 | 70 | def getCurrentFitMode(self) -> str: 71 | """ 72 | Returns the current fit page mode. 73 | 74 | Returns: 75 | str: The current fit page mode. 76 | """ 77 | return self._mainModel.getCurrentFitMode() 78 | 79 | def getCurrentComicPath(self) -> str: 80 | """ 81 | Gets the current comic path. 82 | 83 | Returns: 84 | str: The current comic path. 85 | """ 86 | return self._mainModel.getCurrentComicPath() 87 | 88 | def getPreviousComicPath(self) -> Optional[str]: 89 | """ 90 | Gets the path to the previous comic. 91 | 92 | Returns: 93 | Optional[str]: The path to the previous comic, or None if unavailable. 94 | """ 95 | return self._mainModel.getPreviousComicPath() 96 | 97 | def getNextComicPath(self) -> Optional[str]: 98 | """ 99 | Gets the path to the next comic. 100 | 101 | Returns: 102 | Optional[str]: The path to the next comic, or None if unavailable. 103 | """ 104 | return self._mainModel.getNextComicPath() 105 | 106 | def _loadComic(self, filename: str) -> None: 107 | """ 108 | Loads a comic file and initializes the loader. 109 | 110 | This method creates a loader for the specified comic file and starts it 111 | in a separate thread using QThreadPool. Signals from the loader are connected 112 | to the appropriate slots to handle progress updates and completion. 113 | 114 | Args: 115 | filename (str): The path to the comic file to be loaded. 116 | """ 117 | logger.info("Loading comic file: %s", filename) 118 | self._loader = ComicLoaderFactory.createLoader(filename) 119 | 120 | # Connect signals 121 | self._loader.getSignals().startProgressSignal.connect(self.startProgressSignal.emit) 122 | self._loader.getSignals().loadProgressSignal.connect(self.loadProgressSignal.emit) 123 | self._loader.getSignals().doneProgressSignal.connect(self.onDoneProgress) 124 | self._loader.getSignals().errorLoadSignal.connect(self.errorLoadSignal.emit) 125 | self._loader.getSignals().finishProgressSignal.connect(self.finishProgressSignal.emit) 126 | 127 | self.threadpool = QThreadPool() 128 | self.threadpool.start(self._loader) 129 | 130 | @Slot() 131 | def onFinishProgress(self) -> None: 132 | """ 133 | Handles the finish progress signal when the comic loading process is completed. 134 | """ 135 | logger.info("Finish progress signal emitted") 136 | self._loader = None 137 | self.threadpool = None 138 | self.finishProgressSignal.emit() 139 | 140 | @Slot(str, list) 141 | def onDoneProgress(self, filename: str, data: list[Any]) -> None: 142 | """ 143 | Handles the completion of the comic loading process. 144 | 145 | Args: 146 | filename (str): The name of the loaded file. 147 | data (list): The loaded data. 148 | """ 149 | logger.info("Finished loading comic: %s", filename) 150 | comic = self._mainModel.loadComic(filename, data) 151 | comicHandler = self._mainModel.getComicHandler() 152 | 153 | if comicHandler: 154 | self.updatePageBox() 155 | self.updateCentralWidgetContent() 156 | self.updateWindowTitleSignal.emit(comic.getFilename()) 157 | self.doneProgressSignal.emit() 158 | self.updatePageActionsSignal.emit( 159 | comicHandler.isFirstPage(), 160 | comicHandler.isLastPage(), 161 | ) 162 | else: 163 | logger.error("Failed to load comic file: %s", filename) 164 | 165 | @Slot() 166 | def onActionReportBugTriggered(self) -> None: 167 | """ 168 | Opens the default web browser to the application's issue tracker URL. 169 | """ 170 | logger.info("Report a Bug action triggered") 171 | QDesktopServices.openUrl(QUrl(HELP_URL)) 172 | 173 | @Slot() 174 | def onActionPreviousPageTriggered(self) -> None: 175 | """ 176 | Navigates to the previous page of the comic and updates the view. 177 | """ 178 | logger.info("Previous Page action triggered") 179 | comicHandler = self._mainModel.getComicHandler() 180 | 181 | if comicHandler: 182 | comicHandler.goPreviousPage() 183 | self.updateCentralWidgetContent() 184 | self.updatePageBox() 185 | self.updatePageActionsSignal.emit(comicHandler.isFirstPage(), comicHandler.isLastPage()) 186 | 187 | @Slot() 188 | def onActionNextPageTriggered(self) -> None: 189 | """ 190 | Navigates to the next page of the comic and updates the view. 191 | """ 192 | logger.info("Next Page action triggered") 193 | comicHandler = self._mainModel.getComicHandler() 194 | 195 | if comicHandler: 196 | comicHandler.goNextPage() 197 | self.updateCentralWidgetContent() 198 | self.updatePageBox() 199 | self.updatePageActionsSignal.emit(comicHandler.isFirstPage(), comicHandler.isLastPage()) 200 | 201 | @Slot() 202 | def onActionFirstPageTriggered(self) -> None: 203 | """ 204 | Navigates to the first page of the comic and updates the view. 205 | """ 206 | logger.info("First Page action triggered") 207 | comicHandler = self._mainModel.getComicHandler() 208 | 209 | if comicHandler: 210 | comicHandler.goFirstPage() 211 | self.updateCentralWidgetContent() 212 | self.updatePageBox() 213 | self.updatePageActionsSignal.emit(comicHandler.isFirstPage(), comicHandler.isLastPage()) 214 | 215 | @Slot() 216 | def onActionLastPageTriggered(self) -> None: 217 | """ 218 | Navigates to the last page of the comic and updates the view. 219 | """ 220 | logger.info("Last Page action triggered") 221 | comicHandler = self._mainModel.getComicHandler() 222 | 223 | if comicHandler: 224 | comicHandler.goLastPage() 225 | self.updateCentralWidgetContent() 226 | self.updatePageBox() 227 | self.updatePageActionsSignal.emit(comicHandler.isFirstPage(), comicHandler.isLastPage()) 228 | 229 | @Slot() 230 | def onActionPreviousComicTriggered(self) -> None: 231 | """ 232 | Loads the previous comic if available. 233 | """ 234 | logger.info("Previous Comic action triggered") 235 | previousComicPath = self._mainModel.getPreviousComicPath() 236 | if previousComicPath: 237 | self._loadComic(previousComicPath) 238 | else: 239 | logger.warning("No previous comic available") 240 | 241 | @Slot() 242 | def onActionNextComicTriggered(self) -> None: 243 | """ 244 | Loads the next comic if available. 245 | """ 246 | logger.info("Next Comic action triggered") 247 | nextComicPath = self._mainModel.getNextComicPath() 248 | 249 | if nextComicPath: 250 | self._loadComic(nextComicPath) 251 | else: 252 | logger.warning("No next comic available") 253 | 254 | @Slot() 255 | def onPageSpinBoxValueChanged(self, value: int) -> None: 256 | """ 257 | Navigates to the specified page number. 258 | 259 | Args: 260 | value (int): The page number to navigate to. 261 | """ 262 | logger.info("Go To Page action triggered") 263 | comicHandler = self._mainModel.getComicHandler() 264 | 265 | if comicHandler: 266 | comicHandler.setCurrentPageIndex(value - 1) 267 | self.updateCentralWidgetContent() 268 | 269 | @Slot() 270 | def onActionFitGroupTriggered(self, actionFitObjectName: str) -> None: 271 | """ 272 | Adjusts the view to fit the comic based on the specified fit mode. 273 | 274 | Args: 275 | actionFitObjectName (str): The name of the fit mode action. 276 | """ 277 | logger.info("Fit action triggered: %s", actionFitObjectName) 278 | 279 | if self._mainModel.getComic(): 280 | self._mainModel.setCurrentFitMode(actionFitObjectName) 281 | self.updateCentralWidgetContent() 282 | 283 | @Slot() 284 | def onActionRotateLeftTriggered(self) -> None: 285 | """ 286 | Rotates the comic view to the left. 287 | """ 288 | logger.info("Rotate Left action triggered") 289 | 290 | if self._mainModel.getComic(): 291 | self._mainModel.rotatePageLeft() 292 | self.updateCentralWidgetContent() 293 | 294 | @Slot() 295 | def onActionRotateRightTriggered(self) -> None: 296 | """ 297 | Rotates the comic view to the right. 298 | """ 299 | logger.info("Rotate Right action triggered") 300 | 301 | if self._mainModel.getComic(): 302 | self._mainModel.rotatePageRight() 303 | self.updateCentralWidgetContent() 304 | 305 | def saveData(self) -> None: 306 | """ 307 | Saves the current data to the settings manager. 308 | """ 309 | logger.info("Saving data") 310 | self._mainModel.saveData() 311 | --------------------------------------------------------------------------------