15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
30 | Features | 31 | Installation | 32 | Contributing | 33 | Credits 34 |
35 | 36 |
37 |
38 |
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: make61 |
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 |