diff --git a/.gitignore b/.gitignore index b5a6c65..65a6efa 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,11 @@ code/__pycache__/ # Distribution / packaging .Python -build/ +build/* +!/build/linux/ +build/linux/*/ +!/build/windows/ +build/windows/*/ develop-eggs/ dist/ downloads/ @@ -32,6 +36,7 @@ MANIFEST # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec +!build/**/main.spec # Installer logs pip-log.txt @@ -143,4 +148,10 @@ dmypy.json cython_debug/ # Documentation -*.mp4 \ No newline at end of file +*.mp4 + +# Settings +**/bin/*.ini + +# Models +**/bin/manga-ocr diff --git a/README.md b/README.md index d2d7f87..c4c2ae1 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,11 @@ ## Contents - [About](#about) +- [Alternatives](#alternatives) - [User Guide](#user_guide) - [Installation](#installation) - [Acknowledgments](#acknowledgements) -

+
## About Poricom is a desktop program for optical character recognition in manga images. Although it is a manga OCR application, it can recognize text on other type of images as well. The project is a GUI implementation of the [Manga OCR library](https://pypi.org/project/manga-ocr/0.1.5/) (supports Japanese only) and the Tesseract-API python wrapper [tesserocr](https://github.com/sirfz/tesserocr) (supports other languages). See demo below to see how it works. @@ -25,6 +26,11 @@ Perform OCR on the current screen by pressing `Alt+Q`: https://user-images.githubusercontent.com/45705751/161961152-29070fde-03f6-42a7-8569-0ff22ae9b014.mp4 +## Alternatives + - [Cloe](https://github.com/bluaxees/Cloe) - The app is based on Poricom's global snipping functionality. If you downloaded Poricom to use _only_ the global shortcut, it might be better if you use Cloe instead. + - [mokuro](https://github.com/kha-white/mokuro) - Converts manga images to web pages with selectable text. This saves you time and manual effort since textboxes are automatically detected with an almost 100% accuracy. + + ## User Guide Follow the installation instructions [here](#installation). Load a directory with manga images and select text boxes with Japanese text. If you are not getting good results using the default settings, [use the MangaOcr model](#load_model) to improve text detection. @@ -69,11 +75,10 @@ Listed below are some of the features of Poricom. Smaller features that are not + ## Installation Download the latest zip file [here](https://github.com/bluaxees/Poricom/releases/latest/). Decompress the file in the desired directory. Make sure that the `app` folder is in the same folder as the shortcut `Poricom`. -For developers, clone this repo and install requirements: `pip install -r requirements.txt`. Run the app in the command line using `python main.py`. - ### System Requirements Recommended: @@ -82,7 +87,12 @@ Recommended: Approximately 250 MB of free space and 200 MB of memory is needed to run the application using the Tesseract API. If using the Manga OCR model, an additional 450 MB of free space and 800 MB of memory is required. -For developers, the following Python versions are supported: 3.7, 3.8, and 3.9. +### Development Setup + - Clone this repo and install [conda](https://conda.io/projects/conda/en/latest/user-guide/install/index.html). + - Install dependencies by running `conda env create -f environment/base.yaml`. + - Activate the environment with `conda activate poricom-py39` and run the app using `python main.py`. + - If you want to build the app locally, install build dependencies by running `conda env update -f environment/build.yaml`. Then run `pyinstaller main.spec` in the `build` directory. + ## Acknowledgements This project will not be possible without the MangaOcr model by [Maciej Budyƛ](https://github.com/kha-white) and the Tesseract python wrapper by [sirfz](https://github.com/sirfz) and [the tesserocr contributors](https://github.com/sirfz/tesserocr/graphs/contributors). diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..b1e2d50 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,17 @@ +""" +Poricom +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" diff --git a/code/assets/images/home.png b/app/assets/images/home.png similarity index 100% rename from code/assets/images/home.png rename to app/assets/images/home.png diff --git a/code/assets/images/icons/captureExternalHelper.png b/app/assets/images/icons/captureExternalHelper.png similarity index 100% rename from code/assets/images/icons/captureExternalHelper.png rename to app/assets/images/icons/captureExternalHelper.png diff --git a/code/assets/images/icons/cb_contract.png b/app/assets/images/icons/cb_contract.png similarity index 100% rename from code/assets/images/icons/cb_contract.png rename to app/assets/images/icons/cb_contract.png diff --git a/code/assets/images/icons/cb_expand.png b/app/assets/images/icons/cb_expand.png similarity index 100% rename from code/assets/images/icons/cb_expand.png rename to app/assets/images/icons/cb_expand.png diff --git a/code/assets/images/icons/default_icon.png b/app/assets/images/icons/default_icon.png similarity index 100% rename from code/assets/images/icons/default_icon.png rename to app/assets/images/icons/default_icon.png diff --git a/app/assets/images/icons/hideExplorer.png b/app/assets/images/icons/hideExplorer.png new file mode 100644 index 0000000..9a7d5a6 Binary files /dev/null and b/app/assets/images/icons/hideExplorer.png differ diff --git a/code/assets/images/icons/input_dialog_down.png b/app/assets/images/icons/input_dialog_down.png similarity index 100% rename from code/assets/images/icons/input_dialog_down.png rename to app/assets/images/icons/input_dialog_down.png diff --git a/code/assets/images/icons/input_dialog_up.png b/app/assets/images/icons/input_dialog_up.png similarity index 100% rename from code/assets/images/icons/input_dialog_up.png rename to app/assets/images/icons/input_dialog_up.png diff --git a/code/assets/images/icons/loadImageAtIndex.png b/app/assets/images/icons/loadImageAtIndex.png similarity index 100% rename from code/assets/images/icons/loadImageAtIndex.png rename to app/assets/images/icons/loadImageAtIndex.png diff --git a/code/assets/images/icons/loadModel.png b/app/assets/images/icons/loadModel.png similarity index 100% rename from code/assets/images/icons/loadModel.png rename to app/assets/images/icons/loadModel.png diff --git a/code/assets/images/icons/loadNextImage.png b/app/assets/images/icons/loadNextImage.png similarity index 100% rename from code/assets/images/icons/loadNextImage.png rename to app/assets/images/icons/loadNextImage.png diff --git a/code/assets/images/icons/loadPrevImage.png b/app/assets/images/icons/loadPrevImage.png similarity index 100% rename from code/assets/images/icons/loadPrevImage.png rename to app/assets/images/icons/loadPrevImage.png diff --git a/code/assets/images/icons/modifyTesseract.png b/app/assets/images/icons/loadTranslateModel.png similarity index 100% rename from code/assets/images/icons/modifyTesseract.png rename to app/assets/images/icons/loadTranslateModel.png diff --git a/code/assets/images/icons/logo.ico b/app/assets/images/icons/logo.ico similarity index 100% rename from code/assets/images/icons/logo.ico rename to app/assets/images/icons/logo.ico diff --git a/app/assets/images/icons/logo.svg b/app/assets/images/icons/logo.svg new file mode 100644 index 0000000..027275c --- /dev/null +++ b/app/assets/images/icons/logo.svg @@ -0,0 +1,41 @@ + + + + +Created by potrace 1.16, written by Peter Selinger 2001-2019 + + + + + + + + + diff --git a/code/assets/images/icons/modifyFontSettings.png b/app/assets/images/icons/modifyFontSettings.png similarity index 100% rename from code/assets/images/icons/modifyFontSettings.png rename to app/assets/images/icons/modifyFontSettings.png diff --git a/code/assets/images/icons/modifyHotkeys.png b/app/assets/images/icons/modifyHotkeys.png similarity index 100% rename from code/assets/images/icons/modifyHotkeys.png rename to app/assets/images/icons/modifyHotkeys.png diff --git a/code/assets/images/icons/scaleImage.png b/app/assets/images/icons/modifyImageScaling.png similarity index 100% rename from code/assets/images/icons/scaleImage.png rename to app/assets/images/icons/modifyImageScaling.png diff --git a/code/assets/images/icons/openDir.png b/app/assets/images/icons/openDir.png similarity index 100% rename from code/assets/images/icons/openDir.png rename to app/assets/images/icons/openDir.png diff --git a/code/assets/images/icons/openManga.png b/app/assets/images/icons/openManga.png similarity index 100% rename from code/assets/images/icons/openManga.png rename to app/assets/images/icons/openManga.png diff --git a/code/assets/images/icons/toggleLogging.png b/app/assets/images/icons/toggleLogging.png similarity index 100% rename from code/assets/images/icons/toggleLogging.png rename to app/assets/images/icons/toggleLogging.png diff --git a/code/assets/images/icons/toggleMouseMode.png b/app/assets/images/icons/toggleMouseMode.png similarity index 100% rename from code/assets/images/icons/toggleMouseMode.png rename to app/assets/images/icons/toggleMouseMode.png diff --git a/code/assets/images/icons/toggleSplitView.png b/app/assets/images/icons/toggleSplitView.png similarity index 100% rename from code/assets/images/icons/toggleSplitView.png rename to app/assets/images/icons/toggleSplitView.png diff --git a/code/assets/images/icons/toggleStylesheet.png b/app/assets/images/icons/toggleStylesheet.png similarity index 100% rename from code/assets/images/icons/toggleStylesheet.png rename to app/assets/images/icons/toggleStylesheet.png diff --git a/code/assets/images/icons/zoomIn.png b/app/assets/images/icons/zoomIn.png similarity index 100% rename from code/assets/images/icons/zoomIn.png rename to app/assets/images/icons/zoomIn.png diff --git a/code/assets/images/icons/zoomOut.png b/app/assets/images/icons/zoomOut.png similarity index 100% rename from code/assets/images/icons/zoomOut.png rename to app/assets/images/icons/zoomOut.png diff --git a/code/assets/images/poricom-about.png b/app/assets/images/poricom-about.png similarity index 100% rename from code/assets/images/poricom-about.png rename to app/assets/images/poricom-about.png diff --git a/code/assets/languages/chi_sim.traineddata b/app/assets/languages/chi_sim.traineddata similarity index 100% rename from code/assets/languages/chi_sim.traineddata rename to app/assets/languages/chi_sim.traineddata diff --git a/code/assets/languages/chi_sim_vert.traineddata b/app/assets/languages/chi_sim_vert.traineddata similarity index 100% rename from code/assets/languages/chi_sim_vert.traineddata rename to app/assets/languages/chi_sim_vert.traineddata diff --git a/code/assets/languages/chi_tra.traineddata b/app/assets/languages/chi_tra.traineddata similarity index 100% rename from code/assets/languages/chi_tra.traineddata rename to app/assets/languages/chi_tra.traineddata diff --git a/code/assets/languages/chi_tra_vert.traineddata b/app/assets/languages/chi_tra_vert.traineddata similarity index 100% rename from code/assets/languages/chi_tra_vert.traineddata rename to app/assets/languages/chi_tra_vert.traineddata diff --git a/code/assets/languages/eng.traineddata b/app/assets/languages/eng.traineddata similarity index 100% rename from code/assets/languages/eng.traineddata rename to app/assets/languages/eng.traineddata diff --git a/code/assets/languages/eng_vert.traineddata b/app/assets/languages/eng_vert.traineddata similarity index 100% rename from code/assets/languages/eng_vert.traineddata rename to app/assets/languages/eng_vert.traineddata diff --git a/code/assets/languages/jpn.traineddata b/app/assets/languages/jpn.traineddata similarity index 100% rename from code/assets/languages/jpn.traineddata rename to app/assets/languages/jpn.traineddata diff --git a/code/assets/languages/jpn_vert.traineddata b/app/assets/languages/jpn_vert.traineddata similarity index 100% rename from code/assets/languages/jpn_vert.traineddata rename to app/assets/languages/jpn_vert.traineddata diff --git a/code/assets/languages/kor.traineddata b/app/assets/languages/kor.traineddata similarity index 100% rename from code/assets/languages/kor.traineddata rename to app/assets/languages/kor.traineddata diff --git a/code/assets/languages/kor_vert.traineddata b/app/assets/languages/kor_vert.traineddata similarity index 100% rename from code/assets/languages/kor_vert.traineddata rename to app/assets/languages/kor_vert.traineddata diff --git a/code/assets/styles-dark.qss b/app/assets/styles-dark.qss similarity index 98% rename from code/assets/styles-dark.qss rename to app/assets/styles-dark.qss index 998b4bd..f3fd6c0 100644 --- a/code/assets/styles-dark.qss +++ b/app/assets/styles-dark.qss @@ -184,6 +184,12 @@ QAbstractSpinBox:hover { border: 0.1em solid #6A88CB; } +QTextEdit { + background-color: #31363b; + color: #eff0f1; + font-size: 16pt; +} + QAbstractSpinBox:up-button{ width: 0em; height: 0em; diff --git a/code/assets/styles.qss b/app/assets/styles.qss similarity index 98% rename from code/assets/styles.qss rename to app/assets/styles.qss index 90613ce..38c0777 100644 --- a/code/assets/styles.qss +++ b/app/assets/styles.qss @@ -184,6 +184,12 @@ QAbstractSpinBox:hover { border: 0.1em solid #3b3b42; } +QTextEdit { + background-color: #9394a5; + color: #eff0f1; + font-size: 16pt; +} + QAbstractSpinBox:up-button{ width: 0em; height: 0em; diff --git a/code/utils/unrar.exe b/app/bin/unrar.exe similarity index 100% rename from code/utils/unrar.exe rename to app/bin/unrar.exe diff --git a/app/components/__init__.py b/app/components/__init__.py new file mode 100644 index 0000000..d8be93f --- /dev/null +++ b/app/components/__init__.py @@ -0,0 +1,17 @@ +""" +Poricom Components +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" diff --git a/app/components/explorers/__init__.py b/app/components/explorers/__init__.py new file mode 100644 index 0000000..7912357 --- /dev/null +++ b/app/components/explorers/__init__.py @@ -0,0 +1,19 @@ +""" +Poricom Explorers +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from .image import ImageExplorer diff --git a/app/components/explorers/image.py b/app/components/explorers/image.py new file mode 100644 index 0000000..66ca09e --- /dev/null +++ b/app/components/explorers/image.py @@ -0,0 +1,106 @@ +""" +Poricom Explorers +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from os import listdir, path as ospath +from typing import TYPE_CHECKING + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QFileDialog, QTreeView + +from .models import ImageModel +from utils.constants import EXPLORER_ROOT_DEFAULT, IMAGE_EXTENSIONS + +if TYPE_CHECKING: + from components.views import WorkspaceView + + +class ImageExplorer(QTreeView): + """View to allow exploring images + + Args: + parent (WorkspaceView): Image explorer parent. Set to workspace view. + initialDir (str, optional): Initial directory. Defaults to EXPLORER_ROOT_DEFAULT. + """ + + def __init__( + self, parent: "WorkspaceView", initialDir: str = EXPLORER_ROOT_DEFAULT + ): + super().__init__(parent) + self.setModel(ImageModel()) + + for i in range(1, 4): + self.hideColumn(i) + self.setIndentation(0) + + self.layoutCheck = False + if not ospath.exists(initialDir): + initialDir = EXPLORER_ROOT_DEFAULT + self.setDirectory(initialDir) + + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + def setDirectory(self, path: str): + self.setRootIndex(self.model().setRootPath(path)) + self.setTopIndex() + + def getDirectory(self, startPath: str, isManga=False): + if isManga: + filename, _ = QFileDialog.getOpenFileName( + self.parent(), + "Open Manga File", + startPath, + "Manga (*.cbz *.cbr *.zip *.rar *.pdf)", + ) + return filename + filepath = QFileDialog.getExistingDirectory( + self.parent(), "Open Directory", startPath + ) + if not filepath: + return filepath + for file in listdir(filepath): + try: + _, extension = file.split(".") + if "*." + extension in IMAGE_EXTENSIONS: + return filepath + except ValueError: + continue + return None + + def currentChanged(self, current, previous): + if not current.isValid(): + current = self.model().index(self.getTopIndex(), 0, self.rootIndex()) + filename = self.model().fileInfo(current).absoluteFilePath() + nextIndex = self.indexBelow(current) + filenext = self.model().fileInfo(nextIndex).absoluteFilePath() + self.parent().viewImageFromExplorer(filename, filenext) + super().currentChanged(current, previous) + + def getTopIndex(self): + return self.model().getTopIndex(self.rootIndex()) + + def setTopIndex(self): + topIndex = self.model().index(self.getTopIndex(), 0, self.rootIndex()) + if topIndex.isValid(): + self.setCurrentIndex(topIndex) + if self.layoutCheck: + self.model().layoutChanged.disconnect(self.setTopIndex) + self.layoutCheck = False + else: + if not self.layoutCheck: + self.model().layoutChanged.connect(self.setTopIndex) + self.layoutCheck = True diff --git a/app/components/explorers/models/__init__.py b/app/components/explorers/models/__init__.py new file mode 100644 index 0000000..2ab9145 --- /dev/null +++ b/app/components/explorers/models/__init__.py @@ -0,0 +1,19 @@ +""" +Poricom Explorers +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from .image import ImageModel diff --git a/app/components/explorers/models/image.py b/app/components/explorers/models/image.py new file mode 100644 index 0000000..ccda546 --- /dev/null +++ b/app/components/explorers/models/image.py @@ -0,0 +1,60 @@ +""" +Poricom Explorers +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from PyQt5.QtCore import QModelIndex +from PyQt5.QtWidgets import QFileSystemModel + +from utils.constants import IMAGE_EXTENSIONS + + +class ImageModel(QFileSystemModel): + """ + Image model based on the native file system + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setNameFilterDisables(False) + self.setNameFilters(IMAGE_EXTENSIONS) + + def getTopIndex(self, parentIndex: QModelIndex): + """Get the index of the top most file in the view + + Args: + parentIndex (QModelIndex): Root index of the parent view + + Returns: + int: Index of the top most file + """ + item = self.index(0, 0, parentIndex) + if self.fileInfo(item).isFile(): + return 0 + + r = self.rowCount(parentIndex) // 2 + while True: + item = self.index(r, 0, parentIndex) + if not item.isValid(): + break + if self.fileInfo(item).isFile(): + r //= 2 + elif not self.fileInfo(item).isFile(): + r += 1 + item = self.index(r, 0, parentIndex) + if self.fileInfo(item).isFile(): + break + return r diff --git a/app/components/misc/__init__.py b/app/components/misc/__init__.py new file mode 100644 index 0000000..f9553f0 --- /dev/null +++ b/app/components/misc/__init__.py @@ -0,0 +1,20 @@ +""" +Poricom Misc Components + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from .screenAware import ScreenAwareWidget diff --git a/app/components/misc/screenAware.py b/app/components/misc/screenAware.py new file mode 100644 index 0000000..fd1da21 --- /dev/null +++ b/app/components/misc/screenAware.py @@ -0,0 +1,38 @@ +""" +Poricom Screen Aware Component + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from PyQt5.QtWidgets import QApplication, QWidget + + +class ScreenAwareWidget(QWidget): + """ + Screen-aware widget. Allows retrieving desktop screen dimensions + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def primaryScreen(self): + return QApplication.primaryScreen() + + def primaryScreenWidth(self): + return self.primaryScreen().geometry().width() + + def primaryScreenHeight(self): + return self.primaryScreen().geometry().height() diff --git a/app/components/popups/__init__.py b/app/components/popups/__init__.py new file mode 100644 index 0000000..1563758 --- /dev/null +++ b/app/components/popups/__init__.py @@ -0,0 +1,21 @@ +""" +Poricom Popups + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from .base import BasePopup +from .checkbox import CheckboxPopup diff --git a/app/components/popups/base.py b/app/components/popups/base.py new file mode 100644 index 0000000..cda9e26 --- /dev/null +++ b/app/components/popups/base.py @@ -0,0 +1,43 @@ +""" +Poricom Popups + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QMessageBox + + +class BasePopup(QMessageBox): + """Base popup object to display info + + Args: + title (str): Text to show on the title bar + message (str): Text to show on the main area + buttons (StandardButtons, optional): Buttons to show below the popup. + Defaults to Ok button + """ + + def __init__( + self, + title: str, + message: str, + buttons: QMessageBox.StandardButtons = QMessageBox.Ok, + *args, + **kwargs + ): + super().__init__(QMessageBox.NoIcon, title, message, buttons, *args, **kwargs) + self.setAttribute(Qt.WA_DeleteOnClose) diff --git a/app/components/popups/checkbox.py b/app/components/popups/checkbox.py new file mode 100644 index 0000000..67971f2 --- /dev/null +++ b/app/components/popups/checkbox.py @@ -0,0 +1,53 @@ +""" +Poricom Popups + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from PyQt5.QtCore import QSettings +from PyQt5.QtWidgets import QCheckBox + +from components.popups import BasePopup +from utils.constants import SETTINGS_FILE_DEFAULT + + +class CheckboxPopup(BasePopup): + """Popup message with a checkbox + + Args: + prop (str): Name of the boolean property to be saved. + checkboxMessage (str, optional): Checkbox label. + Defaults to "Don't show this dialog again". + """ + + def __init__( + self, + prop: str, + title: str, + message: str, + buttons: BasePopup.StandardButtons = BasePopup.Ok, + checkboxMessage="Don't show this dialog again", + ): + super().__init__(title, message, buttons) + + self.setCheckBox(QCheckBox(checkboxMessage, self)) + + self.prop = prop + self.accepted.connect(self.saveSettings) + + def saveSettings(self): + settings = QSettings(SETTINGS_FILE_DEFAULT, QSettings.IniFormat) + settings.setValue(self.prop, not self.checkBox().isChecked()) diff --git a/app/components/settings/__init__.py b/app/components/settings/__init__.py new file mode 100644 index 0000000..d48b808 --- /dev/null +++ b/app/components/settings/__init__.py @@ -0,0 +1,28 @@ +""" +Poricom Settings + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from .base import BaseSettings +from .popups import ( + ImageScalingOptions, + ModelOptions, + OptionsContainer, + PreviewOptions, + ShortcutOptions, + TranslateOptions, +) diff --git a/app/components/settings/base.py b/app/components/settings/base.py new file mode 100644 index 0000000..d023284 --- /dev/null +++ b/app/components/settings/base.py @@ -0,0 +1,138 @@ +""" +Poricom Settings + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from typing import Any, Callable + +from PyQt5.QtCore import QSettings +from PyQt5.QtWidgets import QWidget + +from components.popups import BasePopup +from utils.constants import SETTINGS_FILE_DEFAULT + + +class BaseSettings(QWidget): + """Base settings widget to allow save/load/reset of settings + + Args: + parent (QWidget): Parent widget. Set to SettingsMenu object. + file (str): Path to configuration file. Must be in ini format. Defaults to SETTINGS_FILE_DEFAULT. + prefix (str, optional): Text added to the saved property. Defaults to "". + """ + + def __init__( + self, parent: QWidget, file: str = SETTINGS_FILE_DEFAULT, prefix: str = "" + ): + super().__init__(parent) + self.settings = QSettings(file, QSettings.IniFormat) + + # Settings widgets may sometimes share the same configuration file. + # Set the prefix to a unique value to avoid this. + self._prefix = prefix + + self.setDefaults({}) + self.setTypes({}) + + def setDefaults(self, defaults: dict[str, Any]): + """Set the default dictionary + + `self._defaults` contains the default values for ALL properties. + Any property that is saved/loaded from settings should have a default. + Otherwise, the property will not be saved/loaded. + """ + self._defaults = defaults + + def addDefaults(self, defaults: dict[str, Any]): + """ + Extends the defaults dictionary, if it exists + """ + try: + self.setDefaults({**self._defaults, **defaults}) + except AttributeError: + self.setDefaults(defaults) + + def setTypes(self, types: dict[str, Callable]): + """Set the types dictionary + + By default, if the value is a non-QVariant, it is read as a str. + Use `self._types` to set the correct property type. + """ + self._types = types + + def addTypes(self, types: dict[str, Callable]): + """ + Extends the types dictionary, if it exists + """ + try: + self.setTypes({**self._types, **types}) + except AttributeError: + self.setTypes(types) + + def getProperty(self, prop: str): + return getattr(self, prop) + + def setProperty(self, prop: str, value: Any): + try: + t = self._types[prop] + if t == bool: + v = value if type(value) == bool else value.lower() == "true" + return setattr(self, prop, v) + return setattr(self, prop, t(value)) + except KeyError: + return setattr(self, prop, value) + + def addProperty(self, prop: str, value: Any, t: Callable = str): + self._defaults[prop] = value + self._types[prop] = t + self.setProperty(prop, value) + + def removeProperty(self, prop: str): + del self._defaults[prop] + del self._types[prop] + + def saveSettings(self, hasMessage=True): + for propName, _ in self._defaults.items(): + self.settings.setValue( + f"{self._prefix}{propName}", self.getProperty(propName) + ) + if hasMessage: + BasePopup("Save Settings", "Configuration has been saved.").exec() + + def loadSettings(self, settings: dict[str, Any] = {}): + if not settings: + settings = self._defaults + for propName, propDefault in settings.items(): + prop = self.settings.value(f"{self._prefix}{propName}", propDefault) + self.setProperty(propName, prop) + + def confirmResetSettings(self): + confirm = BasePopup( + "Reset Settings", + "Are you sure? This will delete the current configuration.", + BasePopup.Ok | BasePopup.Cancel, + ) + response = confirm.exec() + if response == BasePopup.Ok: + self.resetSettings() + + def resetSettings(self): + try: + self.settings.clear() + except Exception: + pass + self.loadSettings() diff --git a/app/components/settings/popups/__init__.py b/app/components/settings/popups/__init__.py new file mode 100644 index 0000000..a095c27 --- /dev/null +++ b/app/components/settings/popups/__init__.py @@ -0,0 +1,25 @@ +""" +Poricom Settings + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from .container import OptionsContainer +from .imageScaling import ImageScalingOptions +from .model import ModelOptions +from .preview import PreviewOptions +from .shortcut import ShortcutOptions +from .translate import TranslateOptions diff --git a/app/components/settings/popups/base.py b/app/components/settings/popups/base.py new file mode 100644 index 0000000..beb99b2 --- /dev/null +++ b/app/components/settings/popups/base.py @@ -0,0 +1,95 @@ +""" +Poricom Settings + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from typing import Any, Callable + +from stringcase import titlecase, capitalcase +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QComboBox, QGridLayout, QLabel, QWidget + +from ..base import BaseSettings + + +class BaseOptions(BaseSettings): + """ + Allows saving/selecting options + """ + + def __init__(self, parent: QWidget, optionLists: list[list[str]] = []): + super().__init__(parent) + self.mainWindow = parent + self.setAttribute(Qt.WA_DeleteOnClose) + + self.setLayout(QGridLayout()) + self.layout().setContentsMargins(0, 0, 0, 0) + + self.comboBoxList: list[QComboBox] = [] + self.labelList: list[QLabel] = [] + + for i in range(len(optionLists)): + optionList = optionLists[i] + + self.comboBoxList.append(QComboBox()) + self.comboBoxList[i].addItems(optionList) + self.layout().addWidget(self.comboBoxList[i], i, 1) + self.labelList.append(QLabel("")) + self.layout().addWidget(self.labelList[i], i, 0) + + def setOptionIndex(self, option: str, index: int = 0): + """Set the combo box index based on the option name + + Args: + option (str): Option name in camelcase + index (int, optional): Combo box index. Defaults to 0. + """ + optionIndex = self.settings.value(f"{option}Index", index, int) + comboBox = self.getProperty(f"{option}ComboBox") + comboBox.setCurrentIndex(optionIndex) + self.addProperty(f"{option}Index", optionIndex, int) + + def initializeProperties(self, props: list[tuple[str, Any, Callable]]): + """Initialize property values and names + + Args: + props (list[tuple[str, Any, Callable]]): List of props. \ + Each prop must have the following format: (name, default, type). + It is recommended that the name is in camelcase. + + Note: + Child classes must implement change{PropName} method + """ + for i, p in enumerate(props): + # Property + prop, propDefault, propType = p + self.addProperty(prop, propDefault, propType) + + # Label + label = self.labelList[i] + label.setText(f"{titlecase(prop)}: ") + self.setProperty(f"{prop}Label", label) + + # Combo Box + comboBox = self.comboBoxList[i] + self.setProperty(f"{prop}ComboBox", comboBox) + + # Child classes must implement change{PropName} method + comboBox.currentIndexChanged.connect( + self.getProperty(f"change{capitalcase(prop)}") + ) + self.setOptionIndex(prop) diff --git a/app/components/settings/popups/container.py b/app/components/settings/popups/container.py new file mode 100644 index 0000000..369ca4c --- /dev/null +++ b/app/components/settings/popups/container.py @@ -0,0 +1,59 @@ +""" +Poricom settings + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout + +from .base import BaseOptions + + +class OptionsContainer(QDialog): + """Dialog to contain option widgets + + Args: + options (BaseOptions): Child option widget + """ + + def __init__(self, options: BaseOptions): + super().__init__( + None, + Qt.WindowCloseButtonHint | Qt.WindowSystemMenuHint | Qt.WindowTitleHint, + ) + self.setAttribute(Qt.WA_DeleteOnClose) + + self.options = options + self.setLayout(QVBoxLayout()) + self.layout().addWidget(options) + self.buttonBox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + self.layout().addWidget(self.buttonBox) + + self.buttonBox.rejected.connect(self.cancelClickedEvent) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + + def accept(self): + self.options.saveSettings(hasMessage=False) + return super().accept() + + def cancelClickedEvent(self): + self.close() + + def closeEvent(self, event): + self.options.close() + return super().closeEvent(event) diff --git a/app/components/settings/popups/imageScaling.py b/app/components/settings/popups/imageScaling.py new file mode 100644 index 0000000..6731c88 --- /dev/null +++ b/app/components/settings/popups/imageScaling.py @@ -0,0 +1,38 @@ +""" +Poricom Settings + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from PyQt5.QtWidgets import QWidget + +from .base import BaseOptions +from utils.constants import IMAGE_SCALING + + +class ImageScalingOptions(BaseOptions): + def __init__(self, parent: QWidget): + super().__init__(parent, [IMAGE_SCALING]) + # TODO: Use constants here + # TODO: Image scaling must be an enum not an int + self.initializeProperties([("imageScaling", 0, int)]) + + def changeImageScaling(self, i): + self.imageScalingIndex = i + + def saveSettings(self, hasMessage=False): + self.mainWindow.canvas.modifyViewImageMode(self.imageScalingIndex) + return super().saveSettings(hasMessage) diff --git a/app/components/settings/popups/model.py b/app/components/settings/popups/model.py new file mode 100644 index 0000000..534a99a --- /dev/null +++ b/app/components/settings/popups/model.py @@ -0,0 +1,99 @@ +""" +Poricom Popups + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from PyQt5.QtWidgets import QWidget + +from utils.constants import LANGUAGE, OCR_MODEL, ORIENTATION, TOGGLE_CHOICES + +from .base import BaseOptions + + +class ModelOptions(BaseOptions): + def __init__(self, parent: QWidget): + super().__init__(parent, [OCR_MODEL, TOGGLE_CHOICES, LANGUAGE, ORIENTATION]) + # TODO: Use constants here + self.initializeProperties( + [ + ("ocrModel", "MangaOCR", str), + ("useOcrOffline", "false", bool), + ("language", "jpn", str), + ("orientation", "_vert", str), + ] + ) + self.updateDisplay() + + def updateDisplay(self): + ocrModelName = self.ocrModelComboBox.currentText().strip() + if ocrModelName == "MangaOCR": + self.languageLabel.hide() + self.languageComboBox.hide() + self.orientationLabel.hide() + self.orientationComboBox.hide() + elif ocrModelName == "Tesseract": + self.languageLabel.show() + self.languageComboBox.show() + self.orientationLabel.show() + self.orientationComboBox.show() + else: + self.languageLabel.show() + self.languageComboBox.show() + self.orientationLabel.hide() + self.orientationComboBox.hide() + + def changeOcrModel(self, i): + self.ocrModelIndex = i + try: + self.updateDisplay() + # Handle case where extra widgets are still undefined + except AttributeError as e: + print(e) + + def changeUseOcrOffline(self, i): + self.useOcrOfflineIndex = i + self.useOcrOffline = True if i else False + + def changeLanguage(self, i): + self.languageIndex = i + selectedLanguage = self.languageComboBox.currentText().strip() + if selectedLanguage == "Japanese": + self.language = "jpn" + if selectedLanguage == "Korean": + self.language = "kor" + if selectedLanguage == "Chinese SIM": + self.language = "chi_sim" + if selectedLanguage == "Chinese TRA": + self.language = "chi_tra" + if selectedLanguage == "English": + self.language = "eng" + + def changeOrientation(self, i): + self.orientationIndex = i + selectedOrientation = self.orientationComboBox.currentText().strip() + if selectedOrientation == "Vertical": + self.orientation = "_vert" + if selectedOrientation == "Horizontal": + self.orientation = "" + + def saveSettings(self, hasMessage=True): + ocrModelName = self.ocrModelComboBox.currentText().strip() + self.mainWindow.state.setOCRModelName(ocrModelName) + self.mainWindow.setProperty( + "useOcrOffline", "true" if self.useOcrOffline else "useOcrOffline" + ) + return super().saveSettings(hasMessage) diff --git a/app/components/settings/popups/preview.py b/app/components/settings/popups/preview.py new file mode 100644 index 0000000..bf8b9be --- /dev/null +++ b/app/components/settings/popups/preview.py @@ -0,0 +1,62 @@ +""" +Poricom Settings + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from PyQt5.QtWidgets import QWidget + +from .base import BaseOptions +from utils.constants import FONT_SIZE, FONT_STYLE, TOGGLE_CHOICES +from utils.scripts import editStylesheet + + +class PreviewOptions(BaseOptions): + def __init__(self, parent: QWidget): + super().__init__(parent, [FONT_STYLE, FONT_SIZE, TOGGLE_CHOICES]) + self.initializeProperties( + [ + ("fontStyle", " font-family: 'Helvetica';\n", str), + ("fontSize", " font-size: 16pt;\n", str), + ("persistText", "true", bool), + ] + ) + self.setOptionIndex("fontSize", 2) + self.setOptionIndex("persistText", 1) + + def changeFontStyle(self, i): + self.fontStyleIndex = i + selectedFontStyle = self.fontStyleComboBox.currentText().strip() + replacementText = f" font-family: '{selectedFontStyle}';\n" + self.fontStyle = replacementText + + def changeFontSize(self, i): + self.fontSizeIndex = i + selectedFontSize = int(self.fontSizeComboBox.currentText().strip()) + replacementText = f" font-size: {selectedFontSize}pt;\n" + self.fontSize = replacementText + + def changePersistText(self, i): + self.persistTextIndex = i + self.persistText = True if i else False + + def saveSettings(self, hasMessage=False): + editStylesheet(41, self.fontStyle) + editStylesheet(42, self.fontSize) + self.mainWindow.canvas.setProperty( + "persistText", "true" if self.persistText else "false" + ) + return super().saveSettings(hasMessage) diff --git a/app/components/settings/popups/shortcut.py b/app/components/settings/popups/shortcut.py new file mode 100644 index 0000000..eaad453 --- /dev/null +++ b/app/components/settings/popups/shortcut.py @@ -0,0 +1,66 @@ +""" +Poricom Settings + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from PyQt5.QtWidgets import QLabel, QLineEdit, QWidget + +from .base import BaseOptions +from components.popups import BasePopup +from utils.constants import MODIFIER + + +class ShortcutOptions(BaseOptions): + def __init__(self, parent: QWidget): + super().__init__(parent, [MODIFIER]) + self.initializeProperties([("modifier", "Alt", str)]) + self.setOptionIndex("modifier", 2) + self.addDefaults( + {"captureExternalKey": "Q", "captureExternalShortcut": "Alt+Q"} + ) + self.loadSettings() + + self.keyLineEdit = QLineEdit(self.captureExternalKey) + self.layout().addWidget(self.keyLineEdit, 1, 1) + self.layout().addWidget(QLabel("Key: "), 1, 0) + + def raiseKeyInvalidError(self, message: str): + BasePopup("Invalid Key", message).exec() + + def changeModifier(self, i): + self.modifierIndex = i + self.modifier = self.modifierComboBox.currentText().strip() + "+" + if self.modifier == "No Modifier+": + self.modifier = "" + + def changeShortcut(self): + self.captureExternalShortcut = self.modifier + self.captureExternalKey + + def saveSettings(self, hasMessage=False): + if not self.keyLineEdit.text().isalnum(): + self.raiseKeyInvalidError("Please select an alphanumeric key.") + return + if len(self.keyLineEdit.text()) != 1: + self.raiseKeyInvalidError("Please select exactly one key.") + return + self.captureExternalKey = self.keyLineEdit.text() + + self.changeShortcut() + + super().saveSettings(hasMessage) + + BasePopup("Shortcut Remapped", "Close the app to apply changes.").exec() diff --git a/app/components/settings/popups/translate.py b/app/components/settings/popups/translate.py new file mode 100644 index 0000000..ebc39b0 --- /dev/null +++ b/app/components/settings/popups/translate.py @@ -0,0 +1,77 @@ +""" +Poricom Popups + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from PyQt5.QtWidgets import QLabel, QLineEdit, QWidget + +from utils.constants import TRANSLATE_MODEL, TOGGLE_CHOICES + +from .base import BaseOptions + + +class TranslateOptions(BaseOptions): + def __init__(self, parent: QWidget): + super().__init__(parent, [TOGGLE_CHOICES, TRANSLATE_MODEL]) + # TODO: Use constants here + self.initializeProperties( + [ + ("enableTranslate", "false", bool), + ("translateModel", "ArgosTranslate", str), + ] + ) + + i = len(self.comboBoxList) + self.apiLabel = QLabel("API Key") + self.apiLineEdit = QLineEdit(self.mainWindow.state.translateApiKey, self) + self.layout().addWidget(self.apiLabel, i, 0) + self.layout().addWidget(self.apiLineEdit, i, 1) + self.updateDisplay() + + def updateDisplay(self): + translateModelName = self.translateModelComboBox.currentText().strip() + if translateModelName == "ArgosTranslate": + self.apiLabel.hide() + self.apiLineEdit.hide() + elif translateModelName == "ChatGPT" or translateModelName == "DeepL": + self.apiLabel.show() + self.apiLineEdit.show() + else: + self.apiLabel.hide() + self.apiLineEdit.hide() + + def changeEnableTranslate(self, i): + self.enableTranslateIndex = i + self.enableTranslate = True if i else False + + def changeTranslateModel(self, i): + self.translateModelIndex = i + try: + self.updateDisplay() + # Handle case where extra widgets are still undefined + except AttributeError as e: + print(e) + + def saveSettings(self, hasMessage=True): + translateModelName = self.translateModelComboBox.currentText().strip() + translateApiKey = self.apiLineEdit.text().strip() + self.mainWindow.state.setTranslateModelName(translateModelName) + self.mainWindow.state.setTranslateApiKey(translateApiKey) + self.mainWindow.setProperty( + "enableTranslate", "true" if self.enableTranslate else "enableTranslate" + ) + return super().saveSettings(hasMessage) diff --git a/app/components/toolbar/__init__.py b/app/components/toolbar/__init__.py new file mode 100644 index 0000000..f5d9ef7 --- /dev/null +++ b/app/components/toolbar/__init__.py @@ -0,0 +1,19 @@ +""" +Poricom Toolbar +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from .base import BaseToolbar diff --git a/app/components/toolbar/base.py b/app/components/toolbar/base.py new file mode 100644 index 0000000..2da74e9 --- /dev/null +++ b/app/components/toolbar/base.py @@ -0,0 +1,46 @@ +""" +Poricom Toolbar + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from PyQt5.QtWidgets import QMainWindow, QSizePolicy, QTabWidget + +from .tabs import BaseToolbarTab, NavigateToolbarContainer +from utils.constants import TOOLBAR_FUNCTIONS + + +class BaseToolbar(QTabWidget): + """ + Toolbar widget + + Args: + parent (QMainWindow): Toolbar parent. Set to main window. + Notes: + Parent must be passed to children to call main window functions. + """ + + def __init__(self, parent: QMainWindow): + super(QTabWidget, self).__init__(parent) + self.parent = parent + + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + for tabName, funcs in TOOLBAR_FUNCTIONS.items(): + tab = BaseToolbarTab(parent=self.parent, funcs=funcs) + tab.layout().addStretch() + tab.layout().addWidget(NavigateToolbarContainer(self.parent)) + self.addTab(tab, tabName.upper()) diff --git a/app/components/toolbar/tabs/__init__.py b/app/components/toolbar/tabs/__init__.py new file mode 100644 index 0000000..ab83bc8 --- /dev/null +++ b/app/components/toolbar/tabs/__init__.py @@ -0,0 +1,20 @@ +""" +Poricom Toolbar +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from .base import BaseToolbarTab +from .containers import NavigateToolbarContainer diff --git a/app/components/toolbar/tabs/base.py b/app/components/toolbar/tabs/base.py new file mode 100644 index 0000000..9285868 --- /dev/null +++ b/app/components/toolbar/tabs/base.py @@ -0,0 +1,43 @@ +""" +Poricom Toolbar + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from PyQt5.QtWidgets import QHBoxLayout, QMainWindow + +from .containers import BaseToolbarContainer +from utils.types import ButtonConfigDict + + +class BaseToolbarTab(BaseToolbarContainer): + """Tab widget to arrange toolbar tab containers + + Args: + parent (QMainWindow): Toolbar tab parent. Set to main window. + funcs (ButtonConfigDict, optional): Toolbar function configuration. Defaults to {}. + """ + + def __init__(self, parent: QMainWindow, funcs: ButtonConfigDict = {}): + super().__init__(parent) + + self.initializeButtons(funcs) + + def initializeButtons(self, funcs: ButtonConfigDict): + self.setLayout(QHBoxLayout()) + for name, config in funcs.items(): + self.initializeButton(name, config) + self.layout().addWidget(self.buttonList[-1]) diff --git a/app/components/toolbar/tabs/containers/__init__.py b/app/components/toolbar/tabs/containers/__init__.py new file mode 100644 index 0000000..09f3348 --- /dev/null +++ b/app/components/toolbar/tabs/containers/__init__.py @@ -0,0 +1,21 @@ +""" +Poricom Toolbar + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from .base import BaseToolbarContainer +from .navigate import NavigateToolbarContainer diff --git a/app/components/toolbar/tabs/containers/base.py b/app/components/toolbar/tabs/containers/base.py new file mode 100644 index 0000000..f889f04 --- /dev/null +++ b/app/components/toolbar/tabs/containers/base.py @@ -0,0 +1,86 @@ +""" +Poricom Toolbar + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from os.path import exists + +from PyQt5.QtCore import QSize +from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import QMainWindow, QPushButton + +from components.misc import ScreenAwareWidget +from utils.constants import TOOLBAR_ICON_DEFAULT, TOOLBAR_ICON_SIZE, TOOLBAR_ICONS +from utils.types import ButtonConfig + + +class BaseToolbarContainer(ScreenAwareWidget): + """Widget that contains the toolbar functions + + Args: + parent (QMainWindow): Container parent. Set to main window. + """ + + def __init__(self, parent: QMainWindow): + super().__init__(parent) + + # Manually set parent since `addTab` method will reparent the widget + self.mainWindow = parent + self.buttonList: list[QPushButton] = [] + + def addButton(self): + """Adds a QPushButton object to `buttonList` + + Returns: + QPushButton: Recently added QPushButton + """ + self.buttonList.append(QPushButton(self)) + return self.buttonList[-1] + + def initializeButton(self, name: str, config: ButtonConfig): + button = self.addButton() + + # Allows to programmatically interact with buttons + button.setObjectName(name) + + # Set button icon and size + path = TOOLBAR_ICONS + config["path"] + if exists(path): + icon = QIcon(path) + else: + icon = QIcon(TOOLBAR_ICON_DEFAULT) + button.setIcon(icon) + w = self.primaryScreenHeight() * TOOLBAR_ICON_SIZE * config["iconWidth"] + h = self.primaryScreenHeight() * TOOLBAR_ICON_SIZE * config["iconHeight"] + button.setIconSize(QSize(w, h)) + + tooltip = f"\ +

{config['title']}

\ +

{config['message']}

\ + " + button.setToolTip(tooltip) + + button.setCheckable(config["toggle"]) + + # Connect button to main window function + try: + button.clicked.connect(getattr(self.mainWindow, name)) + except AttributeError: + try: + button.clicked.connect(getattr(self.mainWindow.mainView, name)) + except AttributeError: + button.clicked.connect(getattr(self.mainWindow, "noop")) diff --git a/app/components/toolbar/tabs/containers/navigate.py b/app/components/toolbar/tabs/containers/navigate.py new file mode 100644 index 0000000..f9f441c --- /dev/null +++ b/app/components/toolbar/tabs/containers/navigate.py @@ -0,0 +1,49 @@ +""" +Poricom Toolbar + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from PyQt5.QtWidgets import QGridLayout, QMainWindow + +from .base import BaseToolbarContainer +from utils.constants import NAVIGATION_FUNCTIONS + + +class NavigateToolbarContainer(BaseToolbarContainer): + """Widget that contains the toolbar navigation functions + + Args: + parent (QWidget): Container parent. Set to main window. + """ + + def __init__(self, parent: QMainWindow): + super().__init__(parent) + + self.initializeButtons() + + def initializeButtons(self): + self.setLayout(QGridLayout()) + self.layout().setContentsMargins(0, 0, 0, 0) + + for name, config in NAVIGATION_FUNCTIONS.items(): + self.initializeButton(name, config) + + self.layout().addWidget(self.buttonList[0], 0, 0, 1, 1) + self.layout().addWidget(self.buttonList[1], 1, 0, 1, 1) + self.layout().addWidget(self.buttonList[2], 0, 1, 1, 2) + self.layout().addWidget(self.buttonList[3], 1, 1, 1, 1) + self.layout().addWidget(self.buttonList[4], 1, 2, 1, 1) diff --git a/app/components/views/__init__.py b/app/components/views/__init__.py new file mode 100644 index 0000000..bc908b8 --- /dev/null +++ b/app/components/views/__init__.py @@ -0,0 +1,21 @@ +""" +Poricom Views + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from .workspace import WorkspaceView +from .ocr import FullScreenOCRView diff --git a/app/components/views/image/__init__.py b/app/components/views/image/__init__.py new file mode 100644 index 0000000..fd0bfe0 --- /dev/null +++ b/app/components/views/image/__init__.py @@ -0,0 +1,20 @@ +""" +Poricom Views + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from .base import BaseImageView diff --git a/app/components/views/image/base.py b/app/components/views/image/base.py new file mode 100644 index 0000000..8d71831 --- /dev/null +++ b/app/components/views/image/base.py @@ -0,0 +1,226 @@ +""" +Poricom Views + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from time import sleep +from typing import TYPE_CHECKING + +from PyQt5.QtCore import Qt, QRectF, QThreadPool +from PyQt5.QtGui import QTransform +from PyQt5.QtWidgets import QApplication, QGraphicsScene, QGraphicsView + +from components.settings import BaseSettings +from services import BaseWorker, State +from utils.constants import IMAGE_VIEW_DEFAULTS, IMAGE_VIEW_TYPES + +if TYPE_CHECKING: + from ..workspace import WorkspaceView + + +class BaseImageView(QGraphicsView, BaseSettings): + """ + Base image view to allow view/zoom/pan functions + """ + + def __init__(self, parent: "WorkspaceView", state: State = None): + super().__init__(parent) + self.state = state + + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + self.currentScale = 0 + + self._scrollAtMin = 0 + self._scrollAtMax = 0 + self._trackPadAtMin = 0 + self._trackPadAtMax = 0 + self._scrollSuppressed = False + + self.addDefaults(IMAGE_VIEW_DEFAULTS) + self.addTypes(IMAGE_VIEW_TYPES) + self.loadSettings() + + self.initializePixmapItem() + + # ---------------------------------- Settings ----------------------------------- # + + def modifyViewImageMode(self, mode: int): + # TODO: This should be an enum not an int + self.setProperty("viewImageMode", mode) + self.setProperty("imageScalingIndex", mode) + self.saveSettings(hasMessage=False) + self.viewImage() + + def toggleSplitView(self): + self.setProperty("splitViewMode", "false" if self.splitViewMode else "true") + self.saveSettings(hasMessage=False) + + def toggleZoomPanMode(self): + self.setProperty("zoomPanMode", "false" if self.zoomPanMode else "true") + self.saveSettings(hasMessage=False) + + # ------------------------------------ View ------------------------------------- # + + def initializePixmapItem(self): + self.setScene(QGraphicsScene()) + self.pixmap = self.scene().addPixmap(self.state.baseImage) + + def viewImage(self): + # self.verticalScrollBar().setSliderPosition(0) + self.currentScale = 0 + w = self.viewport().geometry().width() + h = self.viewport().geometry().height() + if self.viewImageMode == 0: + self.pixmap.setPixmap( + self.state.baseImage.scaledToWidth(w, Qt.SmoothTransformation) + ) + elif self.viewImageMode == 1: + self.pixmap.setPixmap( + self.state.baseImage.scaledToHeight(h, Qt.SmoothTransformation) + ) + elif self.viewImageMode == 2: + self.pixmap.setPixmap( + self.state.baseImage.scaled( + w, h, Qt.KeepAspectRatio, Qt.SmoothTransformation + ) + ) + self.scene().setSceneRect(QRectF(self.pixmap.pixmap().rect())) + + def zoomView(self, isZoomIn): + if isZoomIn and self.currentScale < 8: + factor = 1.25 + self.currentScale += 1 + self.scale(factor, factor) + elif not isZoomIn and self.currentScale > -8: + factor = 0.8 + self.currentScale -= 1 + self.scale(factor, factor) + + def resizeEvent(self, event): + self.viewImage() + super().resizeEvent(event) + + def wheelEvent(self, event): + pressedKey = QApplication.keyboardModifiers() + zoomMode = pressedKey == Qt.ControlModifier or self.zoomPanMode + + self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) + # TODO: Rewrite individual event handlers as separate functions + if zoomMode: + if event.angleDelta().y() > 0: + isZoomIn = True + elif event.angleDelta().y() < 0: + isZoomIn = False + self.zoomView(isZoomIn) + + if self._scrollSuppressed: + return + + if not zoomMode: + mouseScrollLimit = 3 + trackpadScrollLimit = 36 + wheelDelta = 120 + + def suppressScroll(): + self._scrollSuppressed = True + worker = BaseWorker(sleep, 0.3) + worker.signals.finished.connect( + lambda: setattr(self, "_scrollSuppressed", False) + ) + QThreadPool.globalInstance().start(worker) + + if ( + event.angleDelta().y() < 0 + and self.verticalScrollBar().value() + == self.verticalScrollBar().maximum() + ): + if event.angleDelta().y() > -wheelDelta: + if self._trackPadAtMax == trackpadScrollLimit: + self.parent().loadNextImage() + self._trackPadAtMax = 0 + suppressScroll() + return + else: + self._trackPadAtMax += 1 + elif event.angleDelta().y() <= -wheelDelta: + if self._scrollAtMax == mouseScrollLimit: + self.parent().loadNextImage() + self._scrollAtMax = 0 + suppressScroll() + return + else: + self._scrollAtMax += 1 + + if ( + event.angleDelta().y() > 0 + and self.verticalScrollBar().value() + == self.verticalScrollBar().minimum() + ): + if event.angleDelta().y() < wheelDelta: + if self._trackPadAtMin == trackpadScrollLimit: + self.parent().loadPrevImage() + self._trackPadAtMin = 0 + suppressScroll() + return + else: + self._trackPadAtMin += 1 + elif event.angleDelta().y() >= wheelDelta: + if self._scrollAtMin == mouseScrollLimit: + self.parent().loadPrevImage() + self._scrollAtMin = 0 + suppressScroll() + return + else: + self._scrollAtMin += 1 + super().wheelEvent(event) + + def mouseMoveEvent(self, event): + pressedKey = QApplication.keyboardModifiers() + panMode = pressedKey == Qt.ControlModifier or self.zoomPanMode + + if panMode: + self.setDragMode(QGraphicsView.ScrollHandDrag) + else: + self.setDragMode(QGraphicsView.RubberBandDrag) + + super().mouseMoveEvent(event) + + def mouseDoubleClickEvent(self, event): + self.setTransform(QTransform()) + self.viewImage() + self.verticalScrollBar().setSliderPosition(0) + super().mouseDoubleClickEvent(event) + + # ---------------------------------- Shortcut ----------------------------------- # + + # TODO: Keyboard shortcuts should be in another class + def keyPressEvent(self, event): + if event.key() == Qt.Key_Left: + self.parent().loadPrevImage() + return + if event.key() == Qt.Key_Right: + self.parent().loadNextImage() + return + if event.key() == Qt.Key_Minus: + self.zoomView(isZoomIn=False) + return + if event.key() == Qt.Key_Plus: + self.zoomView(isZoomIn=True) + return + super().keyPressEvent(event) diff --git a/app/components/views/ocr/__init__.py b/app/components/views/ocr/__init__.py new file mode 100644 index 0000000..a39b64a --- /dev/null +++ b/app/components/views/ocr/__init__.py @@ -0,0 +1,21 @@ +""" +Poricom Views + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from .fullscreen import FullScreenOCRView +from .ocr import OCRView diff --git a/app/components/views/ocr/base.py b/app/components/views/ocr/base.py new file mode 100644 index 0000000..e13441d --- /dev/null +++ b/app/components/views/ocr/base.py @@ -0,0 +1,138 @@ +""" +Poricom Views + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from os.path import join + +from PyQt5.QtCore import pyqtSlot, Qt, QThreadPool, QTimer +from PyQt5.QtWidgets import QGraphicsView, QLabel, QMainWindow + +from components.popups import BasePopup +from components.settings import BaseSettings +from services import BaseWorker, State +from utils.constants import ( + TESSERACT_DEFAULTS, + TEXT_LOGGING_DEFAULTS, + TEXT_LOGGING_TYPES, + TRANSLATE_DEFAULTS, + TRANSLATE_TYPES, +) +from utils.scripts import copyToClipboard, logText, pixmapToText + + +class BaseOCRView(QGraphicsView, BaseSettings): + """ + Base view with OCR capabilities + """ + + def __init__(self, parent: QMainWindow, state: State = None): + super().__init__(parent) + self.state = state + + self.timer = QTimer() + self.timer.setInterval(300) + self.timer.setSingleShot(True) + self.timer.timeout.connect(self.rubberBandStopped) + + self.previousSelection = self.rubberBandRect() + + self.canvasText = QLabel("", self, Qt.WindowStaysOnTopHint) + self.canvasText.setWordWrap(True) + self.canvasText.hide() + self.canvasText.setObjectName("canvasText") + + self.setDragMode(QGraphicsView.RubberBandDrag) + + self.addDefaults( + { + **TESSERACT_DEFAULTS, + **TEXT_LOGGING_DEFAULTS, + **TRANSLATE_DEFAULTS, + } + ) + self.addTypes({**TEXT_LOGGING_TYPES, **TRANSLATE_TYPES}) + self.addProperty("persistText", "true", bool) + + def handleTextResult(self, result): + if result == None and self.state.ocrModelName == "Tesseract": + BasePopup( + "Tesseract not loaded", + "Tesseract model cannot be loaded in your machine, please use the MangaOcr instead.", + ).exec() + return + try: + self.canvasText.setText(result) + self.canvasText.adjustSize() + if self.canvasText.isHidden(): + self.canvasText.show() + except RuntimeError: + pass + + def handleTextFinished(self): + try: + self.canvasText.adjustSize() + copyToClipboard(self.canvasText.text()) + except RuntimeError: + pass + try: + self.timer.timeout.connect(self.rubberBandStopped) + except TypeError: + pass + + @pyqtSlot() + def rubberBandStopped(self): + language = self.language + self.orientation + selection = ( + self.previousSelection + if self.rubberBandRect().isNull() + else self.rubberBandRect() + ) + pixmap = self.grab(selection) + + worker = BaseWorker(pixmapToText, pixmap, language, self.state.ocrModel) + worker.signals.result.connect(self.handleTextResult) + worker.signals.finished.connect(self.handleTextFinished) + self.timer.timeout.disconnect(self.rubberBandStopped) + QThreadPool.globalInstance().start(worker) + + def mouseMoveEvent(self, event): + rubberBandVisible = not self.rubberBandRect().isNull() + if (event.buttons() & Qt.LeftButton) and rubberBandVisible: + self.previousSelection = self.rubberBandRect() + self.timer.start() + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + logPath = join(self.explorerPath, "text-log.txt") + text = self.canvasText.text() + if self.logToFile: + logText(text, path=logPath) + + if self.enableTranslate: + self.translateWidget.setSourceText(text) + worker = BaseWorker(self.state.predictTranslate, text) + worker.signals.result.connect(self.translateWidget.setTranslateText) + worker.signals.finished.connect(self.translateWidget.show) + QThreadPool.globalInstance().start(worker) + + try: + if not self.persistText: + self.canvasText.hide() + except AttributeError: + pass + super().mouseReleaseEvent(event) diff --git a/app/components/views/ocr/fullscreen.py b/app/components/views/ocr/fullscreen.py new file mode 100644 index 0000000..70bcac9 --- /dev/null +++ b/app/components/views/ocr/fullscreen.py @@ -0,0 +1,70 @@ +""" +Poricom Views + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from PyQt5.QtCore import Qt, QRectF +from PyQt5.QtWidgets import QApplication, QGraphicsScene, QMainWindow +from PyQt5.QtGui import QCursor, QMouseEvent + +from .base import BaseOCRView +from services import State +from utils.constants import ( + TESSERACT_DEFAULTS, + TEXT_LOGGING_DEFAULTS, + TRANSLATE_DEFAULTS, +) + + +class FullScreenOCRView(BaseOCRView): + """ + Fullscreen view with OCR capabilities + """ + + def __init__(self, parent: QMainWindow, state: State = None): + super().__init__(parent, state) + self.externalWindow = parent + + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + self.translateWidget = parent.mainWindow.mainView.translateView + + self.setScene(QGraphicsScene()) + self.loadSettings( + { + **TESSERACT_DEFAULTS, + **TEXT_LOGGING_DEFAULTS, + **TRANSLATE_DEFAULTS, + } + ) + + def takeScreenshot(self, screenIndex: int): + screen = QApplication.screens()[screenIndex] + s = screen.size() + self.pixmap = self.scene().addPixmap( + screen.grabWindow(0).scaled(s.width(), s.height()) + ) + self.scene().setSceneRect(QRectF(self.pixmap.pixmap().rect())) + + def getActiveScreenIndex(self): + cursor = QCursor.pos() + return QApplication.desktop().screenNumber(cursor) + + def mouseReleaseEvent(self, event: QMouseEvent): + super().mouseReleaseEvent(event) + self.externalWindow.close() diff --git a/app/components/views/ocr/ocr.py b/app/components/views/ocr/ocr.py new file mode 100644 index 0000000..98fb47f --- /dev/null +++ b/app/components/views/ocr/ocr.py @@ -0,0 +1,38 @@ +""" +Poricom Views + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from PyQt5.QtCore import pyqtSlot +from PyQt5.QtWidgets import QMainWindow + +from ..image import BaseImageView +from .base import BaseOCRView +from services import State + + +class OCRView(BaseImageView, BaseOCRView): + def __init__(self, parent: QMainWindow, state: State = None): + super().__init__(parent, state) + + self.translateWidget = parent.translateView + + self.loadSettings() + + @pyqtSlot() + def rubberBandStopped(self): + super().rubberBandStopped() diff --git a/app/components/views/translate.py b/app/components/views/translate.py new file mode 100644 index 0000000..c5cab49 --- /dev/null +++ b/app/components/views/translate.py @@ -0,0 +1,33 @@ +import cutlet +from PyQt5.QtWidgets import QLabel, QTextEdit, QVBoxLayout, QWidget + + +class TranslateView(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + self.ocrLineEdit = QTextEdit("") + self.romajiLineEdit = QTextEdit("") + self.translateLineEdit = QTextEdit("") + + self.setLayout(QVBoxLayout()) + self.layout().addWidget(QLabel("Detected Text")) + self.layout().addWidget(self.ocrLineEdit) + self.layout().addWidget(QLabel("Romaji")) + self.layout().addWidget(self.romajiLineEdit) + self.layout().addWidget(QLabel("Translation")) + self.layout().addWidget(self.translateLineEdit) + + self.katakanaToRomaji = cutlet.Cutlet() + + def setSourceText(self, text: str): + self.ocrLineEdit.setText(text) + try: + romajiText = self.katakanaToRomaji.romaji(text) + except Exception as e: + print(e) + romajiText = "" + self.romajiLineEdit.setText(romajiText) + + def setTranslateText(self, text: str): + self.translateLineEdit.setText(text) diff --git a/app/components/views/workspace.py b/app/components/views/workspace.py new file mode 100644 index 0000000..e886faa --- /dev/null +++ b/app/components/views/workspace.py @@ -0,0 +1,165 @@ +""" +Poricom Workspace View Component + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from PyQt5.QtCore import Qt, QThreadPool +from PyQt5.QtWidgets import QInputDialog, QMainWindow, QSplitter + +from .ocr import OCRView +from .translate import TranslateView +from components.explorers import ImageExplorer +from components.popups import BasePopup +from components.settings import BaseSettings, ImageScalingOptions, OptionsContainer +from services import BaseWorker, State +from utils.constants import EXPLORER_ROOT_DEFAULT, MAIN_VIEW_DEFAULTS, MAIN_VIEW_RATIO +from utils.scripts import mangaFileToImageDir + + +class WorkspaceView(QSplitter, BaseSettings): + """ + Main view of the program. Includes the explorer and the view. + """ + + def __init__(self, parent: QMainWindow, state: State): + super().__init__(parent) + self.mainWindow = parent + self.state = state + + self.setDefaults(MAIN_VIEW_DEFAULTS) + self.loadSettings() + + self.translateView = TranslateView(self) + self.canvas = OCRView(self, self.state) + self.explorer = ImageExplorer(self, self.explorerPath) + self.addWidget(self.explorer) + self.addWidget(self.canvas) + self.addWidget(self.translateView) + self.setChildrenCollapsible(False) + for i, s in enumerate(MAIN_VIEW_RATIO): + self.setStretchFactor(i, s) + + def resizeEvent(self, event): + self.explorer.setMinimumWidth(0.15 * self.width()) + self.canvas.setMinimumWidth(0.6 * self.width()) + self.translateView.setMinimumWidth(0.2 * self.width()) + return super().resizeEvent(event) + + # ---------------------------------- Explorer ----------------------------------- # + + def openDir(self): + filepath = self.explorer.getDirectory(self.explorerPath) + + if filepath: + self.explorer.setDirectory(filepath) + self.explorerPath = filepath + elif filepath == None: + BasePopup( + "No images found", + "Please select a directory with images.", + ).exec() + + def openManga(self): + filename = self.explorer.getDirectory(self.explorerPath, True) + + if filename: + self.explorerPath = EXPLORER_ROOT_DEFAULT + + worker = BaseWorker(mangaFileToImageDir, filename) + worker.signals.result.connect(self.explorer.setDirectory) + QThreadPool.globalInstance().start(worker) + + def hideExplorer(self): + self.explorer.setVisible(not self.explorer.isVisible()) + + # ------------------------------------ View ------------------------------------- # + + def viewImageFromExplorer(self, filename, filenext): + if not self.canvas.splitViewMode: + self.state.baseImage = filename + if self.canvas.splitViewMode: + self.state.baseImage = (filename, filenext) + if not self.state.baseImage.isValid(): + return False + self.canvas.resetTransform() + self.canvas.currentScale = 1 + self.canvas.verticalScrollBar().setSliderPosition(0) + self.canvas.viewImage() + return True + + def toggleSplitView(self): + self.canvas.toggleSplitView() + if self.canvas.splitViewMode: + self.canvas.modifyViewImageMode(2) + index = self.explorer.currentIndex() + self.explorer.currentChanged(index, index) + elif not self.canvas.splitViewMode: + index = self.explorer.currentIndex() + self.explorer.currentChanged(index, index) + + def modifyImageScaling(self): + OptionsContainer(ImageScalingOptions(self)).exec() + + # ------------------------------------ Zoom ------------------------------------- # + + def toggleMouseMode(self): + self.canvas.toggleZoomPanMode() + + def zoomIn(self): + self.canvas.zoomView(True) + + def zoomOut(self): + self.canvas.zoomView(False) + + # --------------------------------- Navigation ---------------------------------- # + + def loadPrevImage(self): + index = self.explorer.indexAbove(self.explorer.currentIndex()) + if self.canvas.splitViewMode: + tempIndex = self.explorer.indexAbove(index) + if tempIndex.isValid(): + index = tempIndex + if not index.isValid(): + return + self.explorer.setCurrentIndex(index) + + def loadNextImage(self): + index = self.explorer.indexBelow(self.explorer.currentIndex()) + if self.canvas.splitViewMode: + tempIndex = self.explorer.indexBelow(index) + if tempIndex.isValid(): + index = tempIndex + if not index.isValid(): + return + self.explorer.setCurrentIndex(index) + + def loadImageAtIndex(self): + rowCount = self.explorer.model().rowCount(self.explorer.rootIndex()) + i, _ = QInputDialog.getInt( + self, + "Jump to", + f"Enter page number: (max is {rowCount})", + value=-1, + min=1, + max=rowCount, + flags=Qt.CustomizeWindowHint | Qt.WindowTitleHint, + ) + if i == -1: + return + + index = self.explorer.model().index(i - 1, 0, self.explorer.rootIndex()) + self.explorer.setCurrentIndex(index) diff --git a/app/components/windows/__init__.py b/app/components/windows/__init__.py new file mode 100644 index 0000000..639b764 --- /dev/null +++ b/app/components/windows/__init__.py @@ -0,0 +1,20 @@ +""" +Poricom Windows + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from .base import MainWindow diff --git a/app/components/windows/base.py b/app/components/windows/base.py new file mode 100644 index 0000000..51e17b8 --- /dev/null +++ b/app/components/windows/base.py @@ -0,0 +1,227 @@ +""" +Poricom Windows + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import re +from shutil import rmtree +from time import sleep + +from PyQt5.QtCore import Qt, QThreadPool +from PyQt5.QtWidgets import ( + QApplication, + QFileDialog, + QMainWindow, + QPushButton, + QVBoxLayout, + QWidget, +) + +from .external import ExternalWindow +from components.popups import BasePopup, CheckboxPopup +from components.settings import ( + BaseSettings, + ModelOptions, + OptionsContainer, + PreviewOptions, + ShortcutOptions, + TranslateOptions, +) +from components.toolbar import BaseToolbar +from components.views import WorkspaceView +from services import BaseWorker, State +from utils.constants import ( + LOAD_MODEL_MESSAGE, + MAIN_WINDOW_DEFAULTS, + MAIN_WINDOW_TYPES, + PORICOM_CACHE, + STYLESHEET_DARK, + STYLESHEET_LIGHT, + TRANSLATE_DEFAULTS, + TRANSLATE_TYPES, +) + + +class MainWindow(QMainWindow, BaseSettings): + def __init__(self, parent: QWidget = None): + super().__init__(parent) + self.state = State() + + self.vLayout = QVBoxLayout() + + self.mainView = WorkspaceView(self, self.state) + self.toolbar = BaseToolbar(self) + self.vLayout.addWidget(self.toolbar) + + self.vLayout.addWidget(self.mainView) + mainWidget = QWidget() + mainWidget.setLayout(self.vLayout) + self.setCentralWidget(mainWidget) + + self.setDefaults({**MAIN_WINDOW_DEFAULTS, **TRANSLATE_DEFAULTS}) + self.setTypes({**MAIN_WINDOW_TYPES, **TRANSLATE_TYPES}) + self.loadSettings() + + self.threadpool = QThreadPool() + + @property + def canvas(self): + return self.mainView.canvas + + @property + def explorer(self): + return self.mainView.explorer + + def closeEvent(self, event): + try: + rmtree(PORICOM_CACHE) + except FileNotFoundError: + pass + self.saveSettings(False) + self.mainView.saveSettings(False) + self.canvas.close() + return super().closeEvent(event) + + def noop(self): + BasePopup("Not Implemented", "This function is not yet implemented.").exec() + + # ------------------------------- File Functions -------------------------------- # + + def captureExternalHelper(self): + self.showMinimized() + sleep(0.5) + if self.isMinimized(): + self.captureExternal() + + def captureExternal(self): + ExternalWindow(self).showFullScreen() + + # ------------------------------- View Functions -------------------------------- # + + def toggleStylesheet(self): + if self.stylesheetPath == STYLESHEET_LIGHT: + self.stylesheetPath = STYLESHEET_DARK + elif self.stylesheetPath == STYLESHEET_DARK: + self.stylesheetPath = STYLESHEET_LIGHT + + app = QApplication.instance() + if app is None: + raise RuntimeError("No Qt Application found.") + + with open(self.stylesheetPath, "r") as fh: + app.setStyleSheet(fh.read()) + + def modifyFontSettings(self): + confirmation = OptionsContainer(PreviewOptions(self)) + ret = confirmation.exec() + + if ret: + app = QApplication.instance() + if app is None: + raise RuntimeError("No Qt Application found.") + + with open(self.stylesheetPath, "r") as fh: + app.setStyleSheet(fh.read()) + + # ------------------------------ Control Functions ------------------------------ # + + def modifyHotkeys(self): + OptionsContainer(ShortcutOptions(self)).exec() + + # ------------------------------- Misc Functions -------------------------------- # + + def loadModel(self): + confirmation = OptionsContainer(ModelOptions(self)) + confirmed = confirmation.exec() + + if confirmed: + self.loadSettings({"useOcrOffline": "false"}) + if self.useOcrOffline and not self.mangaOCRPath: + startPath = self.mainView.explorerPath or "." + ocrPath = QFileDialog.getExistingDirectory( + self, "Set MangaOCR Directory", startPath + ) + if ocrPath: + self.mangaOCRPath = ocrPath + elif not self.useOcrOffline: + self.mangaOCRPath = "" + + if confirmed: + self.loadModelAfterPopup() + + def loadModelAfterPopup(self): + loadModelButton = self.toolbar.findChild(QPushButton, "loadModel") + isMangaOCR = self.state.ocrModelName == "MangaOCR" + + if not isMangaOCR: + return + + if isMangaOCR and self.hasLoadModelPopup: + ret = CheckboxPopup( + "hasLoadModelPopup", + "Load the MangaOCR model?", + LOAD_MODEL_MESSAGE, + CheckboxPopup.Ok | CheckboxPopup.Cancel, + ).exec() + if ret == CheckboxPopup.Cancel: + return + self.loadSettings({"hasLoadModelPopup": "true"}) + + def loadModelConfirm(message: str): + modelName = self.state.ocrModelName + if message == "success": + BasePopup( + f"{modelName} model loaded", + f"You are now using the {modelName} model for Japanese text detection.", + ).exec() + else: + BasePopup("Load Model Error", message).exec() + if re.search( + "^unable to parse .* as a URL or as a local path$", message + ): + self.mangaOCRPath = "" + + worker = BaseWorker(self.state.loadOCRModel, self.mangaOCRPath) + worker.signals.result.connect(loadModelConfirm) + worker.signals.finished.connect(lambda: loadModelButton.setEnabled(True)) + + self.threadpool.start(worker) + loadModelButton.setEnabled(False) + + def loadTranslateModel(self): + confirmation = OptionsContainer(TranslateOptions(self)) + confirmed = confirmation.exec() + if confirmed: + self.loadSettings(TRANSLATE_DEFAULTS) + self.canvas.loadSettings(TRANSLATE_DEFAULTS) + self.loadTranslateAfterPopup() + + def loadTranslateAfterPopup(self): + loadModelButton = self.toolbar.findChild(QPushButton, "loadTranslateModel") + if not self.enableTranslate: + self.mainView.translateView.hide() + return + + worker = BaseWorker(self.state.loadTranslateModel) + worker.signals.finished.connect(lambda: loadModelButton.setEnabled(True)) + + self.threadpool.start(worker) + loadModelButton.setEnabled(False) + + def toggleLogging(self): + self.logToFile = not self.logToFile + self.canvas.loadSettings() diff --git a/app/components/windows/external.py b/app/components/windows/external.py new file mode 100644 index 0000000..ab3fe24 --- /dev/null +++ b/app/components/windows/external.py @@ -0,0 +1,71 @@ +""" +Poricom Windows + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from typing import TYPE_CHECKING + +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QCloseEvent +from PyQt5.QtWidgets import QDesktopWidget, QMainWindow + +from components.views import FullScreenOCRView + +if TYPE_CHECKING: + from .base import MainWindow + + +class ExternalWindow(QMainWindow): + """ + External window widget to enclose FullScreenOCRView + """ + + def __init__(self, parent: "MainWindow"): + super().__init__() + self.mainWindow = parent + + # By setting the border thickness and margin to zero, + # we ensure that the whole screen is captured. + self.layout().setContentsMargins(0, 0, 0, 0) + self.setStyleSheet("border:0px; margin:0px") + + # Delete external window on close + self.setAttribute(Qt.WA_DeleteOnClose) + + # WindowStaysOnTopHint & Popup flags ensures that the widget is the top window. + self.setWindowFlags(Qt.WindowStaysOnTopHint | Qt.Popup) + + self.setCentralWidget(FullScreenOCRView(self, parent.state)) + # self.ocrModel = parent.ocrModel + + def showFullScreen(self): + # Overridden to show on the active screen + fullscreen: FullScreenOCRView = self.centralWidget() + screenIndex = fullscreen.getActiveScreenIndex() + + # TODO: Find an alternative way to show the active screen, + # since QDesktopWidget is obsolete according to Qt docs + screen = QDesktopWidget().screenGeometry(screenIndex) + fullscreen.takeScreenshot(screenIndex) + self.move(screen.left(), screen.top()) + + return super().showFullScreen() + + def closeEvent(self, event: QCloseEvent): + # Ensure that object is deleted before closing + self.deleteLater() + return super().closeEvent(event) diff --git a/code/main.py b/app/main.py similarity index 58% rename from code/main.py rename to app/main.py index 6f122c4..0c865fd 100644 --- a/code/main.py +++ b/app/main.py @@ -20,37 +20,37 @@ from PyQt5.QtWidgets import QApplication from PyQt5.QtGui import QIcon -from PyQt5.QtCore import QAbstractEventDispatcher +from PyQt5.QtCore import QAbstractEventDispatcher, QSettings from pyqtkeybind import keybinder -from MainWindow import MainWindow, WinEventFilter -from Trackers import Tracker -from utils.config import config - -if __name__ == '__main__': +from components.windows import MainWindow +from services import WinEventFilter +from utils.constants import APP_NAME, APP_LOGO, SETTINGS_FILE_DEFAULT, STYLESHEET_LIGHT +if __name__ == "__main__": app = QApplication(sys.argv) - app.setApplicationName("Poricom") - app.setWindowIcon(QIcon(config["LOGO"])) + app.setApplicationName(APP_NAME) + app.setWindowIcon(QIcon(APP_LOGO)) + + widget = MainWindow() - tracker = Tracker() - widget = MainWindow(parent=None, tracker=tracker) + settings = QSettings(SETTINGS_FILE_DEFAULT, QSettings.IniFormat) - styles = config["STYLES_DEFAULT"] - with open(styles, 'r') as fh: + styles = settings.value("stylesheetPath", STYLESHEET_LIGHT) + with open(styles, "r") as fh: app.setStyleSheet(fh.read()) + shortcut = settings.value("captureExternalShortcut", "Alt+Q") keybinder.init() - previousShortcut = config["SHORTCUT"]["captureExternal"] - keybinder.register_hotkey( - widget.winId(), config["SHORTCUT"]["captureExternal"], widget.captureExternal) + keybinder.register_hotkey(widget.winId(), shortcut, widget.captureExternal) winEventFilter = WinEventFilter(keybinder) eventDispatcher = QAbstractEventDispatcher.instance() eventDispatcher.installNativeEventFilter(winEventFilter) widget.showMaximized() - widget.loadModel() + widget.loadTranslateAfterPopup() + widget.loadModelAfterPopup() app.exec_() - # keybinder.unregister_hotkey(widget.winId(), previousShortcut) + # keybinder.unregister_hotkey(widget.winId(), shortcut) sys.exit() diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..f82fb96 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,21 @@ +""" +Poricom Services +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from .filters import WinEventFilter +from .states import State +from .workers import BaseWorker, BaseWorkerSignal diff --git a/app/services/filters.py b/app/services/filters.py new file mode 100644 index 0000000..2764923 --- /dev/null +++ b/app/services/filters.py @@ -0,0 +1,30 @@ +""" +Poricom Services + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from PyQt5.QtCore import QAbstractNativeEventFilter + + +class WinEventFilter(QAbstractNativeEventFilter): + def __init__(self, keybinder): + self.keybinder = keybinder + super().__init__() + + def nativeEventFilter(self, eventType, message): + ret = self.keybinder.handler(eventType, message) + return ret, 0 diff --git a/app/services/states.py b/app/services/states.py new file mode 100644 index 0000000..9b81138 --- /dev/null +++ b/app/services/states.py @@ -0,0 +1,249 @@ +""" +Poricom States + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from os.path import isfile, exists +from requests import post +from typing import Literal + +from argostranslate.package import ( + get_available_packages, + install_from_path, + update_package_index, +) +from argostranslate.translate import get_installed_languages +from manga_ocr import MangaOcr +from PyQt5.QtCore import QSettings +from PyQt5.QtGui import QPixmap + +from utils.constants import SETTINGS_FILE_DEFAULT, TRANSLATE_MODEL +from utils.scripts import combineTwoImages + + +class Pixmap(QPixmap): + def __init__(self, *args): + super().__init__(args[0]) + + # Current directory + filename + if type(args[0]) == str: + self._filename = args[0] + if type(args[0]) == QPixmap: + self._filename = args[1] + # Current directory + self._filepath = None + + @property + def filename(self): + return self._filename + + @filename.setter + def filename(self, filename): + self._filename = filename + + def isValid(self): + return exists(self._filename) and isfile(self._filename) + + +OCRModelNames = Literal["Tesseract", "MangaOCR"] +TranslateModelNames = Literal["ArgosTranslate", "ChatGPT", "DeepL"] + + +class State: + def __init__(self): + self._baseImage = Pixmap("") + + settings = QSettings(SETTINGS_FILE_DEFAULT, QSettings.IniFormat) + + ocrModelName = settings.value("ocrModel", "MangaOCR") + self._ocrModel = None + self._ocrModelName: OCRModelNames = ocrModelName + + translateModelIndex = settings.value("translateModelIndex", 0) + translateModelName = TRANSLATE_MODEL[int(translateModelIndex)].strip() + translateApiKey = settings.value("translateApiKey", "") + self._translateModel = None + self._translateModelName: TranslateModelNames = translateModelName + self._translateApiKey = translateApiKey + + # ------------------------------------ Image ------------------------------------ # + + @property + def baseImage(self): + return self._baseImage + + @baseImage.setter + def baseImage(self, image): + if type(image) is str and Pixmap(image).isValid(): + self._baseImage = Pixmap(image) + if type(image) is tuple: + fileLeft, fileRight = image + if not fileRight: + if fileLeft: + self._baseImage = Pixmap(fileLeft) + return + splitImage = combineTwoImages(fileLeft, fileRight) + + self._baseImage = Pixmap(splitImage, fileLeft) + + # ------------------------------------- OCR ------------------------------------- # + + @property + def ocrModel(self): + return self._ocrModel + + @ocrModel.setter + def ocrModel(self, ocrModel): + self._ocrModel = ocrModel + + @property + def ocrModelName(self): + return self._ocrModelName + + def setOCRModelName(self, ocrModelName: OCRModelNames = None): + if ocrModelName: + self._ocrModelName = ocrModelName + else: + self.toggleOCRModelName() + return self._ocrModelName + + def toggleOCRModelName(self): + if self._ocrModelName == "Tesseract": + self._ocrModelName = "MangaOCR" + elif self._ocrModelName == "MangaOCR": + self._ocrModelName = "Tesseract" + return self._ocrModelName + + def loadOCRModel(self, path: str = None): + if self._ocrModelName == "Tesseract": + self._ocrModel = None + return "success" + elif self._ocrModelName == "MangaOCR": + try: + if path: + self.ocrModel = MangaOcr(pretrained_model_name_or_path=path) + else: + self.ocrModel = MangaOcr() + return "success" + except Exception as e: + self.setOCRModelName("Tesseract") + return str(e) + + # ---------------------------------- Translate ---------------------------------- # + + @property + def translateModel(self): + return self._translateModel + + @translateModel.setter + def translateModel(self, translateModel): + self._translateModel = translateModel + + @property + def translateModelName(self): + return self._translateModelName + + def setTranslateModelName(self, translateModelName: TranslateModelNames = None): + self._translateModelName = translateModelName + return self._translateModelName + + @property + def translateApiKey(self): + return self._translateApiKey + + def setTranslateApiKey(self, translateApiKey): + self._translateApiKey = translateApiKey + return self._translateApiKey + + def downloadArgosTranslateModel(self, fromCode="ja", toCode="en"): + update_package_index() + availablePackages = get_available_packages() + availablePackage = list( + filter( + lambda x: x.from_code == fromCode and x.to_code == toCode, + availablePackages, + ) + )[0] + downloadPath = availablePackage.download() + install_from_path(downloadPath) + + def getArgosTranslateModel(self, fromCode="ja", toCode="en"): + installedLanguages = get_installed_languages() + fromLang = list(filter(lambda x: x.code == fromCode, installedLanguages)) + toLang = list(filter(lambda x: x.code == toCode, installedLanguages)) + + if not fromLang or not toLang: + return None + return fromLang[0], toLang[0] + + def loadTranslateModel(self): + if self._translateModelName == "ArgosTranslate": + languages = self.getArgosTranslateModel() + if languages: + fromLang, toLang = languages + self.translateModel = fromLang.get_translation(toLang) + else: + self.downloadArgosTranslateModel() + languages = self.getArgosTranslateModel() + if not languages: + return "Error while loading offline model." + fromLang, toLang = languages + self.translateModel = fromLang.get_translation(toLang) + return "success" + else: + self.translateModel = None + return "success" + + def predictTranslate(self, text): + if self.translateModelName == "ArgosTranslate": + return self.translateModel.translate(text) + elif self.translateModelName == "ChatGPT": + headers = { + "content-type": "application/json", + "authorization": f"Bearer {self.translateApiKey}", + } + body = { + "model": "text-davinci-003", + "prompt": f"Translate this to English:\n{text}", + "temperature": 0.3, + "max_tokens": 128, + } + try: + response = post( + "https://api.openai.com/v1/completions", json=body, headers=headers + ).json() + return response["choices"][0]["text"].strip() + except Exception as e: + print(e) + return text + elif self.translateModelName == "DeepL": + headers = { + "content-type": "application/json", + "authorization": f"DeepL-Auth-Key {self.translateApiKey}", + } + body = { + "text": text, + "target_lang": "EN", + } + try: + response = post( + "https://api-free.deepl.com/v2/translate", json=body, headers=headers + ).json() + return response["translations"]["text"].strip() + except Exception as e: + print(e) + return text diff --git a/app/services/workers/__init__.py b/app/services/workers/__init__.py new file mode 100644 index 0000000..9b002c5 --- /dev/null +++ b/app/services/workers/__init__.py @@ -0,0 +1,20 @@ +""" +Poricom Services +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from .base import BaseWorker +from .signal import BaseWorkerSignal diff --git a/code/Workers.py b/app/services/workers/base.py similarity index 70% rename from code/Workers.py rename to app/services/workers/base.py index 64142c7..d122b85 100644 --- a/code/Workers.py +++ b/app/services/workers/base.py @@ -1,5 +1,5 @@ """ -Poricom Multithreaded Workers +Poricom Services Copyright (C) `2021-2022` `` @@ -17,24 +17,30 @@ along with this program. If not, see . """ -from PyQt5.QtCore import (QRunnable, QObject, pyqtSignal, pyqtSlot) +from typing import Callable + +from PyQt5.QtCore import pyqtSlot, QRunnable + +from .signal import BaseWorkerSignal class BaseWorker(QRunnable): - def __init__(self, fn, *args, **kwargs): + """Runnable object to support multithreading + + Args: + fn (Callable): Long running task or function + *Note: args/kwargs passed onto the BaseWorker are passed onto fn + """ + + def __init__(self, fn: Callable, *args, **kwargs): super(BaseWorker, self).__init__() self.fn = fn self.args = args self.kwargs = kwargs - self.signals = WorkerSignal() + self.signals = BaseWorkerSignal() @pyqtSlot() def run(self): output = self.fn(*self.args, **self.kwargs) self.signals.result.emit(output) self.signals.finished.emit() - - -class WorkerSignal(QObject): - finished = pyqtSignal() - result = pyqtSignal(object) diff --git a/app/services/workers/signal.py b/app/services/workers/signal.py new file mode 100644 index 0000000..26e7f89 --- /dev/null +++ b/app/services/workers/signal.py @@ -0,0 +1,32 @@ +""" +Poricom Services + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from PyQt5.QtCore import pyqtSignal, QObject + + +class BaseWorkerSignal(QObject): + """Base signal object + + Signals: + finished: Emit when thread finished the task + result: Emit the result of the task + """ + + finished = pyqtSignal() + result = pyqtSignal(object) diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..0e99b78 --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1,17 @@ +""" +Poricom Utilities +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" diff --git a/app/utils/constants.py b/app/utils/constants.py new file mode 100644 index 0000000..9bda736 --- /dev/null +++ b/app/utils/constants.py @@ -0,0 +1,308 @@ +""" +Poricom Constants +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from .types import ButtonConfigDict +from sys import platform + +# ------------------------------------- General ------------------------------------- # + +APP_NAME = "Poricom" +APP_LOGO = "./assets/images/icons/logo.ico" + +IMAGE_EXTENSIONS = [ + "*.bmp", + "*.gif", + "*.jpeg", + "*.jpg", + "*.pbm", + "*.pgm", + "*.png", + "*.ppm", + "*.webp", + "*.xbm", + "*.xpm", +] + +# Settings Popup Choices +TOGGLE_CHOICES = [" Disabled", " Enabled"] + +OCR_MODEL = [" MangaOCR", " Tesseract"] +TRANSLATE_MODEL = [" ArgosTranslate", " ChatGPT", " DeepL"] +LANGUAGE = [" Japanese", " Korean", " Chinese SIM", " Chinese TRA ", " English"] +ORIENTATION = [" Vertical", " Horizontal"] + +IMAGE_SCALING = [" Fit to Width", " Fit to Height", " Fit to Screen"] + +FONT_SIZE = [" 12", " 14", " 16", " 20", " 24", " 32", " 40", " 56", " 72"] +FONT_STYLE = [" Helvetica", " Poppins", " Arial", " Verdana", " Times New Roman"] + +MODIFIER = [ + " Ctrl", + " Shift", + " Alt", + " Ctrl+Alt", + " Shift+Alt", + " Shift+Ctrl", + " Shift+Alt+Ctrl", + " No Modifier", +] + +PLATFORM = platform + +# Paths +STYLESHEET_LIGHT = "./assets/styles.qss" +STYLESHEET_DARK = "./assets/styles-dark.qss" + +TESSERACT_LANGUAGES = "./assets/languages/" + +TOOLBAR_ICONS = "./assets/images/icons/" +TOOLBAR_ICON_DEFAULT = "./assets/images/icons/default_icon.png" + +EXPLORER_ROOT_DEFAULT = "./assets/images/" + +PORICOM_CACHE = "/tmp/poricom_cache" if PLATFORM == "linux" else "./poricom_cache" + +# Messages +LOAD_MODEL_MESSAGE = ( + "If you are running this for the first time, this will download the MangaOcr model " + "which is about 400 MB in size. This will improve the accuracy of Japanese text " + "detection in Poricom. If it is already in your cache, it will take a few seconds " + "to load the model." +) + +# ------------------------------------ Settings ------------------------------------- # + +SETTINGS_FILE_DEFAULT = "./bin/poricom-config.ini" + +# Window +MAIN_WINDOW_DEFAULTS = { + "useOcrOffline": "false", + "hasLoadModelPopup": "true", + "logToFile": "false", + "mangaOCRPath": "", + "stylesheetPath": "./assets/styles.qss", +} +MAIN_WINDOW_TYPES = { + "useOcrOffline": bool, + "hasLoadModelPopup": bool, + "logToFile": bool, +} + +# View +MAIN_VIEW_DEFAULTS = {"explorerPath": EXPLORER_ROOT_DEFAULT} +IMAGE_VIEW_DEFAULTS = { + "viewImageMode": 0, + "imageScalingIndex": 0, + "splitViewMode": "false", + "zoomPanMode": "false", +} +IMAGE_VIEW_TYPES = {"viewImageMode": int, "splitViewMode": bool, "zoomPanMode": bool} + +# Tesseract +TESSERACT_DEFAULTS = {"language": "jpn", "orientation": "_vert"} + +# Text Logging +TEXT_LOGGING_DEFAULTS = {"explorerPath": EXPLORER_ROOT_DEFAULT, "logToFile": "false"} +TEXT_LOGGING_TYPES = {"logToFile": bool} + +# Translate +TRANSLATE_DEFAULTS = {"enableTranslate": "false"} +TRANSLATE_TYPES = {"enableTranslate": bool} + +# --------------------------------------- UI ---------------------------------------- # + +# Main view +MAIN_VIEW_RATIO = [3, 19, 4] + +# Toolbar +TOOLBAR_ICON_SIZE = 0.05 # Fraction of primary screen height + +NAVIGATION_FUNCTIONS: ButtonConfigDict = { + "zoomIn": { + "title": "Zoom in", + "message": "Hint: Double click the image to reset zoom.", + "path": "zoomIn.png", + "toggle": False, + "align": "AlignRight", + "iconHeight": 0.45, + "iconWidth": 0.45, + }, + "zoomOut": { + "title": "Zoom out", + "message": "Hint: Double click the image to reset zoom.", + "path": "zoomOut.png", + "toggle": False, + "align": "AlignRight", + "iconHeight": 0.45, + "iconWidth": 0.45, + }, + "loadImageAtIndex": { + "title": "", + "message": "Jump to page", + "path": "loadImageAtIndex.png", + "toggle": False, + "align": "AlignRight", + "iconHeight": 0.45, + "iconWidth": 1.3, + }, + "loadPrevImage": { + "title": "", + "message": "Show previous image", + "path": "loadPrevImage.png", + "toggle": False, + "align": "AlignRight", + "iconHeight": 0.45, + "iconWidth": 0.6, + }, + "loadNextImage": { + "title": "", + "message": "Show next image", + "path": "loadNextImage.png", + "toggle": False, + "align": "AlignRight", + "iconHeight": 0.45, + "iconWidth": 0.6, + }, +} +TOOLBAR_FUNCTIONS: dict[str, ButtonConfigDict] = { + "file": { + "openDir": { + "title": "Open manga directory", + "message": "Open a directory containing images.", + "path": "openDir.png", + "toggle": False, + "align": "AlignLeft", + "iconHeight": 1.0, + "iconWidth": 1.0, + }, + "openManga": { + "title": "Open manga file", + "message": "Supports the following formats: cbr, cbz, pdf.", + "path": "openManga.png", + "toggle": False, + "align": "AlignLeft", + "iconHeight": 1.0, + "iconWidth": 1.0, + }, + "captureExternalHelper": { + "title": "External capture", + "message": "This will minimize the app and perform OCR on the current screen. Alternatively, you may use the shortcut Alt+Q (default).", + "path": "captureExternalHelper.png", + "toggle": False, + "align": "AlignLeft", + "iconHeight": 1.0, + "iconWidth": 1.0, + }, + }, + "view": { + "toggleStylesheet": { + "title": "Change theme", + "message": "Switch between light and dark mode.", + "path": "toggleStylesheet.png", + "toggle": False, + "align": "AlignLeft", + "iconHeight": 1.0, + "iconWidth": 1.0, + }, + "hideExplorer": { + "title": "Hide explorer", + "message": "Hide the file explorer from view", + "path": "hideExplorer.png", + "toggle": True, + "align": "AlignLeft", + "iconHeight": 1.0, + "iconWidth": 1.0, + }, + "modifyFontSettings": { + "title": "Modify preview text", + "message": "Change font style and font size of preview text.", + "path": "modifyFontSettings.png", + "toggle": False, + "align": "AlignLeft", + "iconHeight": 1.0, + "iconWidth": 1.0, + }, + "toggleSplitView": { + "title": "Turn on split view", + "message": "View two images at once.", + "path": "toggleSplitView.png", + "toggle": True, + "align": "AlignLeft", + "iconHeight": 1.0, + "iconWidth": 1.0, + }, + "modifyImageScaling": { + "title": "Adjust image scaling", + "message": "Fit an image according to the available options: fit to width, fit to height, fit to screen", + "path": "modifyImageScaling.png", + "toggle": False, + "align": "AlignLeft", + "iconHeight": 1.0, + "iconWidth": 1.0, + }, + }, + "controls": { + "toggleMouseMode": { + "title": "Change mouse behavior", + "message": "This will disable text detection. Turn this on only if do not want to hold CTRL key to zoom and pan on an image.", + "path": "toggleMouseMode.png", + "toggle": True, + "align": "AlignLeft", + "iconHeight": 1.0, + "iconWidth": 1.0, + }, + "modifyHotkeys": { + "title": "Remap hotkeys", + "message": "Change shortcut for external captures.", + "path": "modifyHotkeys.png", + "toggle": False, + "align": "AlignLeft", + "iconHeight": 1.0, + "iconWidth": 1.0, + }, + }, + "misc": { + "loadModel": { + "title": "Load detection model", + "message": "Manage OCR model settings.", + "path": "loadModel.png", + "toggle": False, + "align": "AlignLeft", + "iconHeight": 1.0, + "iconWidth": 1.0, + }, + "loadTranslateModel": { + "title": "Load translation model", + "message": "Manage translation model settings and API keys.", + "path": "loadTranslateModel.png", + "toggle": False, + "align": "AlignLeft", + "iconHeight": 1.0, + "iconWidth": 1.0, + }, + "toggleLogging": { + "title": "Enable text logging", + "message": "Save detected text to a text file located in the current project directory.", + "path": "toggleLogging.png", + "toggle": True, + "align": "AlignLeft", + "iconHeight": 1.0, + "iconWidth": 1.0, + }, + }, +} diff --git a/app/utils/scripts/__init__.py b/app/utils/scripts/__init__.py new file mode 100644 index 0000000..68b81e0 --- /dev/null +++ b/app/utils/scripts/__init__.py @@ -0,0 +1,25 @@ +""" +Poricom Helper Functions + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from .combineTwoImages import combineTwoImages +from .copyToClipboard import copyToClipboard +from .editStylesheet import editStylesheet +from .logText import logText +from .mangaFileToImageDir import mangaFileToImageDir +from .pixmapToText import pixmapToText diff --git a/app/utils/scripts/combineTwoImages.py b/app/utils/scripts/combineTwoImages.py new file mode 100644 index 0000000..caa4021 --- /dev/null +++ b/app/utils/scripts/combineTwoImages.py @@ -0,0 +1,47 @@ +""" +Poricom Helper Functions + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from typing import Union + +from PyQt5.QtGui import QPixmap, QPainter + + +def combineTwoImages(fileLeft: Union[str, QPixmap], fileRight: Union[str, QPixmap]): + """ + Combines two image files or pixmaps to one pixmap + """ + imageLeft, imageRight = QPixmap(fileRight), QPixmap(fileLeft) + if imageRight.isNull(): + # raise FileNotFoundError("The first file is null.") + pass + + w = imageLeft.width() + imageRight.width() + h = max(imageLeft.height(), imageRight.height()) + if imageLeft.isNull(): + w = imageRight.width() * 2 + h = imageRight.height() + combinedImage = QPixmap(w, h) + painter = QPainter(combinedImage) + painter.drawPixmap(0, 0, imageLeft.width(), imageLeft.height(), imageLeft) + painter.drawPixmap( + imageLeft.width(), 0, imageRight.width(), imageRight.height(), imageRight + ) + painter.end() + + return combinedImage diff --git a/app/utils/scripts/copyToClipboard.py b/app/utils/scripts/copyToClipboard.py new file mode 100644 index 0000000..1d1d40f --- /dev/null +++ b/app/utils/scripts/copyToClipboard.py @@ -0,0 +1,28 @@ +""" +Poricom Helper Functions + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from PyQt5.QtGui import QGuiApplication + + +def copyToClipboard(text: str): + """ + Copy input text to clipboard + """ + clipboard = QGuiApplication.clipboard() + clipboard.setText(text) diff --git a/app/utils/scripts/editStylesheet.py b/app/utils/scripts/editStylesheet.py new file mode 100644 index 0000000..8b9adbd --- /dev/null +++ b/app/utils/scripts/editStylesheet.py @@ -0,0 +1,34 @@ +""" +Poricom Helper Functions + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from ..constants import STYLESHEET_LIGHT, STYLESHEET_DARK + + +def editStylesheet(index: int, style: str): + """ + Replace stylesheet at line `index` with input `style` + """ + with open(STYLESHEET_LIGHT, "r") as slFh, open(STYLESHEET_DARK, "r") as sdFh: + lineLight = slFh.readlines() + linesDark = sdFh.readlines() + lineLight[index] = style + linesDark[index] = style + with open(STYLESHEET_LIGHT, "w") as slFh, open(STYLESHEET_DARK, "w") as sdFh: + slFh.writelines(lineLight) + sdFh.writelines(linesDark) diff --git a/app/utils/scripts/logText.py b/app/utils/scripts/logText.py new file mode 100644 index 0000000..e3378b5 --- /dev/null +++ b/app/utils/scripts/logText.py @@ -0,0 +1,31 @@ +""" +Poricom Helper Functions + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from PyQt5.QtGui import QGuiApplication + + +def logText(text: str, path: str = "."): + """Log by appending text to log file + + Args: + text (str): Text to log. + path (str, optional): Path to log file. Defaults to ".". + """ + with open(path, "a", encoding="utf-8") as fh: + fh.write(text + "\n") diff --git a/app/utils/scripts/mangaFileToImageDir.py b/app/utils/scripts/mangaFileToImageDir.py new file mode 100644 index 0000000..a71fcac --- /dev/null +++ b/app/utils/scripts/mangaFileToImageDir.py @@ -0,0 +1,65 @@ +""" +Poricom Helper Functions + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from os.path import splitext, basename +from pathlib import Path + +import zipfile +import rarfile +import pdf2image + +from utils.constants import PORICOM_CACHE, PLATFORM + + +def mangaFileToImageDir(filepath: str): + """Converts a manga file to a directory of images + + Args: + filepath (str): Path to manga file. + + Returns: + str: Path to directory of images. + """ + extractPath, extension = splitext(filepath) + cachePath = f"{PORICOM_CACHE}/{basename(extractPath)}" + + if extension in [".cbz", ".zip"]: + with zipfile.ZipFile(filepath, "r") as zipRef: + zipRef.extractall(cachePath) + + if "win" in PLATFORM.lower(): + rarfile.UNRAR_TOOL = "bin/unrar.exe" + + if extension in [".cbr", ".rar"]: + with rarfile.RarFile(filepath) as zipRef: + zipRef.extractall(cachePath) + + if extension in [".pdf"]: + try: + images = pdf2image.convert_from_path(filepath) + except pdf2image.exceptions.PDFInfoNotInstalledError: + images = pdf2image.convert_from_path( + filepath, poppler_path="poppler/Library/bin" + ) + for i in range(len(images)): + filename = basename(extractPath) + Path(cachePath).mkdir(parents=True, exist_ok=True) + images[i].save(f"{cachePath}/{i+1}_{filename}.png", "PNG") + + return cachePath diff --git a/app/utils/scripts/pixmapToText.py b/app/utils/scripts/pixmapToText.py new file mode 100644 index 0000000..1cec4f7 --- /dev/null +++ b/app/utils/scripts/pixmapToText.py @@ -0,0 +1,70 @@ +""" +Poricom Helper Functions + +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from io import BytesIO +from typing import Optional + +from manga_ocr import MangaOcr +from PIL import Image +from PyQt5.QtCore import QBuffer +from PyQt5.QtGui import QPixmap + +try: + from tesserocr import PyTessBaseAPI +except UnicodeDecodeError: + pass + +from ..constants import TESSERACT_LANGUAGES + + +def pixmapToText( + pixmap: QPixmap, language: str = "jpn_vert", model: Optional[MangaOcr] = None +) -> str: + """ + Convert QPixmap object to text using the model + """ + + buffer = QBuffer() + buffer.open(QBuffer.ReadWrite) + pixmap.save(buffer, "PNG") + bytes = BytesIO(buffer.data()) + + if bytes.getbuffer().nbytes == 0: + return "" + + pillowImage = Image.open(bytes) + text = "" + + if model is not None: + text = model(pillowImage) + + # PSM = 1 works most of the time except on smaller bounding boxes. + # By smaller, we mean textboxes with less text. Usually these + # boxes have at most one vertical line of text. + else: + try: + with PyTessBaseAPI( + path=TESSERACT_LANGUAGES, lang=language, oem=1, psm=1 + ) as api: + api.SetImage(pillowImage) + text = api.GetUTF8Text() + except NameError: + return None + + return text.strip() diff --git a/app/utils/types.py b/app/utils/types.py new file mode 100644 index 0000000..72de520 --- /dev/null +++ b/app/utils/types.py @@ -0,0 +1,32 @@ +""" +Poricom Types +Copyright (C) `2021-2022` `` + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from typing import TypedDict + + +class ButtonConfig(TypedDict): + title: str + message: str + path: str + toggle: bool + align: str + iconHeight: float + iconWidth: float + + +ButtonConfigDict = dict[str, ButtonConfig] diff --git a/build/linux/build.sh b/build/linux/build.sh new file mode 100755 index 0000000..0840a05 --- /dev/null +++ b/build/linux/build.sh @@ -0,0 +1,23 @@ +echo "Creating Poricom pyinstaller package" +pyinstaller main.spec + +echo "Bundle Poricom for distribution" +mkdir -p package/opt +mkdir -p package/usr/share/applications +mkdir -p package/usr/share/icons/hicolor/scalable/apps + +cp -r dist/app package/opt/poricom +cp poricom.desktop package/usr/share/applications +cp package/opt/poricom/assets/images/icons/logo.svg package/usr/share/icons/hicolor/scalable/apps/logo.svg + +find package/opt/poricom -type f -exec chmod 644 -- {} + +find package/opt/poricom -type d -exec chmod 755 -- {} + +find package/usr/share -type f -exec chmod 644 -- {} + +chmod +x package/opt/poricom/Poricom + +echo "Create deb package" +#sudo apt install ruby -y +#gem install fpm +fpm -C package -s dir -t deb -n "poricom" -v 1.0.0 -p poricom.deb + +echo "Linux build (deb) finished." diff --git a/build/linux/main.spec b/build/linux/main.spec new file mode 100644 index 0000000..f10f623 --- /dev/null +++ b/build/linux/main.spec @@ -0,0 +1,99 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_data_files +from PyInstaller.utils.hooks import copy_metadata +import os, torch, glob + +datas = [] +datas += collect_data_files('unidic_lite') +datas += collect_data_files('manga_ocr') +datas += copy_metadata('tqdm') +datas += copy_metadata('regex') +datas += copy_metadata('requests') +datas += copy_metadata('packaging') +datas += copy_metadata('filelock') +datas += copy_metadata('numpy') +datas += copy_metadata('tokenizers') + +added_files = [ + ('../../app/assets', './assets'), + ('../../app/bin', './bin') +] + +block_cipher = None + +a = Analysis(['../../app/main.py'], + pathex=['../../app'], + binaries=[], + datas=datas+added_files, + hiddenimports= ['stringcase'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False) + +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) + +excluded_binaries = [ + 'libtorch_cpu.so', + 'libtorch_cuda_cpp.so', + 'libtorch_cuda_cu.so', + 'libtorch_cuda_linalg.so' +] +a.binaries = [x for x in a.binaries if not x[0] in excluded_binaries] + +libcudnn_path = os.path.split(torch.__path__[0])[0] + '/nvidia/cudnn/lib' +libcudnn_ops_infer_path = libcudnn_path + '/libcudnn_ops_infer.so.8' +libcudnn_cnn_infer_path = libcudnn_path + '/libcudnn_cnn_infer.so.8' + +included_binaries = [ + ('libcudnn_ops_infer.so.8', libcudnn_ops_infer_path,'BINARY'), + ('libcudnn_cnn_infer.so.8', libcudnn_cnn_infer_path, 'BINARY' ) +] + +a.binaries = a.binaries + included_binaries + +exe = EXE(pyz, + a.scripts, + [], + exclude_binaries=True, + name='Poricom', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon="logo.svg") + +coll = COLLECT(exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='app') + +excluded_files = [ + 'libtorch_cuda_linalg.so', + 'libnccl.so.2', + 'libcufft.so.10', + 'libcusparse.so.11', + 'unrar.exe' +] + +for binary in excluded_files: + for filePath in glob.glob('**/'+ binary, recursive=True): + try: + print("Removing: {}".format(filePath)) + os.remove(filePath) + except OSError: + print("Error while deleting: {}".format(filePath)) diff --git a/build/linux/poricom.desktop b/build/linux/poricom.desktop new file mode 100644 index 0000000..e04b930 --- /dev/null +++ b/build/linux/poricom.desktop @@ -0,0 +1,20 @@ +[Desktop Entry] + +# The type of the thing this desktop file refers to (e.g. can be Link) +Type=Application + +# The application name. +Name=Poricom + +# Tooltip comment to show in menus. +Comment=Manga OCR desktop application + +# The path (folder) in which the executable is run +Path=/opt/poricom + +# The executable (can include arguments) +Exec=/opt/poricom/Poricom + +# The icon for the entry, using the name from `hicolor/scalable` without the extension. +# You can also use a full path to a file in /opt. +Icon=logo diff --git a/build/windows/main.spec b/build/windows/main.spec new file mode 100644 index 0000000..034e30c --- /dev/null +++ b/build/windows/main.spec @@ -0,0 +1,98 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_data_files +from PyInstaller.utils.hooks import copy_metadata + +datas = [] +datas += collect_data_files('unidic_lite') +datas += collect_data_files('manga_ocr') +datas += copy_metadata('tqdm') +datas += copy_metadata('regex') +datas += copy_metadata('requests') +datas += copy_metadata('packaging') +datas += copy_metadata('filelock') +datas += copy_metadata('numpy') +datas += copy_metadata('tokenizers') + +added_files = [ + ('../../app/assets', './assets'), + ('../../app/bin', './bin'), + ('path\\to\\user\\.conda\\pkgs\\poppler-version', './poppler') +] + + +block_cipher = None + + +a = Analysis(['../../app/main.py'], + pathex=['../../app'], + binaries=[], + datas=datas+added_files, + hiddenimports=['stringcase'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False) +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) + + +PATH_TO_TORCH_LIB = "torch\\lib\\" +excluded_files = [ + 'asmjit.lib', + 'c10.lib', + 'clog.lib', + 'cpuinfo.lib', + 'dnnl.lib', + 'caffe2_detectron_ops.dll', + 'caffe2_detectron_ops.lib', + 'caffe2_module_test_dynamic.dll', + 'caffe2_module_test_dynamic.lib', + 'caffe2_observers.dll', + 'caffe2_observers.lib', + 'Caffe2_perfkernels_avx.lib', + 'Caffe2_perfkernels_avx2.lib', + 'Caffe2_perfkernels_avx512.lib', + 'fbgemm.lib', + 'kineto.lib', + 'libprotobuf-lite.lib', + 'libprotobuf.lib', + 'libprotoc.lib', + 'mkldnn.lib', + 'pthreadpool.lib', + 'shm.lib', + 'torch.lib', + 'torch_cpu.lib', + 'torch_python.lib', + 'XNNPACK.lib', + '_C.lib' +] +excluded_files = [PATH_TO_TORCH_LIB + x for x in excluded_files] +a.datas = [x for x in a.datas if not x[0] in excluded_files] + +exe = EXE(pyz, + a.scripts, + [], + exclude_binaries=True, + name='Poricom', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon="../../app/assets/images/icons/logo.ico") +coll = COLLECT(exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='app') diff --git a/code/Explorers.py b/code/Explorers.py deleted file mode 100644 index 1e8283d..0000000 --- a/code/Explorers.py +++ /dev/null @@ -1,70 +0,0 @@ -""" -Poricom Explorer Components - -Copyright (C) `2021-2022` `` - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" - -from PyQt5.QtCore import (Qt, QDir) -from PyQt5.QtWidgets import (QTreeView, QFileSystemModel) - -from utils.config import config - - -class ImageExplorer(QTreeView): - layoutCheck = False - - def __init__(self, parent=None, tracker=None): - super(QTreeView, self).__init__() - self.parent = parent - self.tracker = tracker - - self.model = QFileSystemModel() - # self.model.setFilter(QDir.Files) - self.model.setNameFilterDisables(False) - self.model.setNameFilters(config["IMAGE_EXTENSIONS"]) - self.setModel(self.model) - - for i in range(1, 4): - self.hideColumn(i) - self.setIndentation(0) - - self.setDirectory(tracker.filepath) - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - - def currentChanged(self, current, previous): - if not current.isValid(): - current = self.model.index(0, 0, self.rootIndex()) - filename = self.model.fileInfo(current).absoluteFilePath() - nextIndex = self.indexBelow(current) - filenext = self.model.fileInfo(nextIndex).absoluteFilePath() - self.parent.viewImageFromExplorer(filename, filenext) - QTreeView.currentChanged(self, current, previous) - - def setTopIndex(self): - topIndex = self.model.index(0, 0, self.rootIndex()) - if topIndex.isValid(): - self.setCurrentIndex(topIndex) - if self.layoutCheck: - self.model.layoutChanged.disconnect(self.setTopIndex) - self.layoutCheck = False - else: - if not self.layoutCheck: - self.model.layoutChanged.connect(self.setTopIndex) - self.layoutCheck = True - - def setDirectory(self, path): - self.setRootIndex(self.model.setRootPath(path)) - self.setTopIndex() diff --git a/code/MainWindow.py b/code/MainWindow.py deleted file mode 100644 index 75d4514..0000000 --- a/code/MainWindow.py +++ /dev/null @@ -1,339 +0,0 @@ -""" -Poricom Main Window Component - -Copyright (C) `2021-2022` `` - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" - -from shutil import rmtree -from time import sleep - -import toml -from manga_ocr import MangaOcr -from PyQt5.QtCore import (Qt, QAbstractNativeEventFilter, QThreadPool) -from PyQt5.QtWidgets import (QHBoxLayout, QVBoxLayout, QWidget, - QPushButton, QFileDialog, QInputDialog, QMainWindow, QApplication) - -from utils.image_io import mangaFileToImageDir -from utils.config import config, saveOnClose -from Workers import BaseWorker -from Ribbon import (Ribbon) -from Explorers import (ImageExplorer) -from Views import (OCRCanvas, FullScreen) -from Popups import (FontPicker, LanguagePicker, ScaleImagePicker, - ShortcutPicker, PickerPopup, MessagePopup) - - -class WinEventFilter(QAbstractNativeEventFilter): - def __init__(self, keybinder): - self.keybinder = keybinder - super().__init__() - - def nativeEventFilter(self, eventType, message): - ret = self.keybinder.handler(eventType, message) - return ret, 0 - - -class MainWindow(QMainWindow): - - def __init__(self, parent=None, tracker=None): - super(QWidget, self).__init__(parent) - self.tracker = tracker - self.config = config - - self.vLayout = QVBoxLayout() - self.ribbon = Ribbon(self, self.tracker) - self.vLayout.addWidget(self.ribbon) - self.canvas = OCRCanvas(self, self.tracker) - self.explorer = ImageExplorer(self, self.tracker) - - _viewWidget = QWidget() - hLayout = QHBoxLayout(_viewWidget) - hLayout.addWidget(self.explorer, config["NAV_VIEW_RATIO"][0]) - hLayout.addWidget(self.canvas, config["NAV_VIEW_RATIO"][1]) - hLayout.setContentsMargins(0, 0, 0, 0) - - self.vLayout.addWidget(_viewWidget) - _mainWidget = QWidget() - _mainWidget.setLayout(self.vLayout) - self.setCentralWidget(_mainWidget) - - self.threadpool = QThreadPool() - - def viewImageFromExplorer(self, filename, filenext): - if not self.canvas.splitViewMode(): - self.tracker.pixImage = filename - if self.canvas.splitViewMode(): - self.tracker.pixImage = (filename, filenext) - if not self.tracker.pixImage.isValid(): - return False - self.canvas.resetTransform() - self.canvas.currentScale = 1 - self.canvas.verticalScrollBar().setSliderPosition(0) - self.canvas.viewImage() - return True - - def closeEvent(self, event): - try: - rmtree("./poricom_cache") - except FileNotFoundError: - pass - saveOnClose(self.config) - return QMainWindow.closeEvent(self, event) - - def poricomNoop(self): - MessagePopup( - "WIP", - "This function is not yet implemented." - ).exec() - -# ------------------------------ File Functions ------------------------------ # - - def openDir(self): - filepath = QFileDialog.getExistingDirectory( - self, - "Open Directory", - "." # , QFileDialog.DontUseNativeDialog - ) - - if filepath: - # self.tracker.pixImage = filename - self.tracker.filepath = filepath - self.explorer.setDirectory(filepath) - - def openManga(self): - filename, _ = QFileDialog.getOpenFileName( - self, - "Open Manga File", - ".", - "Manga (*.cbz *.cbr *.zip *.rar *.pdf)" - ) - - if filename: - def setDirectory(filepath): - self.tracker.filepath = filepath - self.explorer.setDirectory(filepath) - - openMangaButton = self.ribbon.findChild( - QPushButton, "openManga") - - worker = BaseWorker(mangaFileToImageDir, filename) - worker.signals.result.connect(setDirectory) - worker.signals.finished.connect( - lambda: openMangaButton.setEnabled(True)) - - self.threadpool.start(worker) - openMangaButton.setEnabled(False) - - def captureExternalHelper(self): - self.showMinimized() - sleep(0.5) - if self.isMinimized(): - self.captureExternal() - - def captureExternal(self): - externalWindow = QMainWindow() - externalWindow.layout().setContentsMargins(0, 0, 0, 0) - externalWindow.setStyleSheet("border:0px; margin:0px") - - externalWindow.setCentralWidget( - FullScreen(externalWindow, self.tracker)) - externalWindow.centralWidget().takeScreenshot() - externalWindow.showFullScreen() - -# ------------------------------ View Functions ------------------------------ # - - def toggleStylesheet(self): - config = "./utils/config.toml" - lightMode = "./assets/styles.qss" - darkMode = "./assets/styles-dark.qss" - - data = toml.load(config) - if data["STYLES_DEFAULT"] == lightMode: - data["STYLES_DEFAULT"] = darkMode - elif data["STYLES_DEFAULT"] == darkMode: - data["STYLES_DEFAULT"] = lightMode - with open(config, 'w') as fh: - toml.dump(data, fh) - - app = QApplication.instance() - if app is None: - raise RuntimeError("No Qt Application found.") - - styles = data["STYLES_DEFAULT"] - self.config["STYLES_DEFAULT"] = data["STYLES_DEFAULT"] - with open(styles, 'r') as fh: - app.setStyleSheet(fh.read()) - - def modifyFontSettings(self): - confirmation = PickerPopup(FontPicker(self, self.tracker)) - ret = confirmation.exec() - - if ret: - app = QApplication.instance() - if app is None: - raise RuntimeError("No Qt Application found.") - - with open(config["STYLES_DEFAULT"], 'r') as fh: - app.setStyleSheet(fh.read()) - - def toggleSplitView(self): - self.canvas.toggleSplitView() - if self.canvas.splitViewMode(): - self.canvas.setViewImageMode(2) - index = self.explorer.currentIndex() - self.explorer.currentChanged(index, index) - elif not self.canvas.splitViewMode(): - index = self.explorer.currentIndex() - self.explorer.currentChanged(index, index) - - def scaleImage(self): - confirmation = PickerPopup(ScaleImagePicker(self, self.tracker)) - confirmation.exec() - -# ----------------------------- Control Functions ---------------------------- # - - def toggleMouseMode(self): - self.canvas.toggleZoomPanMode() - - def modifyHotkeys(self): - confirmation = PickerPopup(ShortcutPicker(self, self.tracker)) - ret = confirmation.exec() - if ret: - MessagePopup( - "Shortcut Remapped", - "Close the app to apply changes." - ).exec() - -# ------------------------------ Misc Functions ------------------------------ # - - def loadModel(self): - loadModelButton = self.ribbon.findChild(QPushButton, "loadModel") - loadModelButton.setChecked(not self.tracker.ocrModel) - - if loadModelButton.isChecked(): - confirmation = MessagePopup( - "Load the MangaOCR model?", - "If you are running this for the first time, this will " + - "download the MangaOcr model which is about 400 MB in size. " + - "This will improve the accuracy of Japanese text detection " + - "in Poricom. If it is already in your cache, it will take a " + - "few seconds to load the model.", - MessagePopup.Ok | MessagePopup.Cancel - ) - ret = confirmation.exec() - if (ret == MessagePopup.Ok): - pass - else: - loadModelButton.setChecked(False) - return - - def loadModelHelper(tracker): - betterOCR = tracker.switchOCRMode() - if betterOCR: - import http.client as httplib - - def isConnected(url="8.8.8.8"): - connection = httplib.HTTPSConnection(url, timeout=2) - try: - connection.request("HEAD", "/") - return True - except Exception: - return False - finally: - connection.close() - - connected = isConnected() - if connected: - tracker.ocrModel = MangaOcr() - return (betterOCR, connected) - else: - tracker.ocrModel = None - return (betterOCR, True) - - def modelLoadedConfirmation(typeConnectionTuple): - usingMangaOCR, connected = typeConnectionTuple - modelName = "MangaOCR" if usingMangaOCR else "Tesseract" - if connected: - MessagePopup( - f"{modelName} model loaded", - f"You are now using the {modelName} model for Japanese text detection." - ).exec() - - elif not connected: - MessagePopup( - "Connection Error", - "Please try again or make sure your Internet connection is on." - ).exec() - loadModelButton.setChecked(False) - - worker = BaseWorker(loadModelHelper, self.tracker) - worker.signals.result.connect(modelLoadedConfirmation) - worker.signals.finished.connect(lambda: - loadModelButton.setEnabled(True)) - - self.threadpool.start(worker) - loadModelButton.setEnabled(False) - - def modifyTesseract(self): - confirmation = PickerPopup(LanguagePicker(self, self.tracker)) - confirmation.exec() - - def toggleLogging(self): - self.tracker.switchWriteMode() - -# --------------------------- Always On Functions ---------------------------- # - - def loadPrevImage(self): - index = self.explorer.indexAbove(self.explorer.currentIndex()) - if self.canvas.splitViewMode(): - tempIndex = self.explorer.indexAbove(index) - if tempIndex.isValid(): - index = tempIndex - if (not index.isValid()): - return - self.explorer.setCurrentIndex(index) - - def loadNextImage(self): - index = self.explorer.indexBelow(self.explorer.currentIndex()) - if self.canvas.splitViewMode(): - tempIndex = self.explorer.indexBelow(index) - if tempIndex.isValid(): - index = tempIndex - if (not index.isValid()): - return - self.explorer.setCurrentIndex(index) - - def loadImageAtIndex(self): - rowCount = self.explorer.model.rowCount(self.explorer.rootIndex()) - i, _ = QInputDialog.getInt( - self, - 'Jump to', - f'Enter page number: (max is {rowCount})', - value=-1, - min=1, - max=rowCount, - flags=Qt.CustomizeWindowHint | Qt.WindowTitleHint) - if (i == -1): - return - - index = self.explorer.model.index(i-1, 0, self.explorer.rootIndex()) - self.explorer.setCurrentIndex(index) - - def zoomIn(self): - self.canvas.zoomView(True, usingButton=True) - - def zoomOut(self): - self.canvas.zoomView(False, usingButton=True) diff --git a/code/Popups.py b/code/Popups.py deleted file mode 100644 index 29024ce..0000000 --- a/code/Popups.py +++ /dev/null @@ -1,244 +0,0 @@ -""" -Poricom Popup Components - -Copyright (C) `2021-2022` `` - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" - -from PyQt5.QtCore import (Qt) -from PyQt5.QtWidgets import (QGridLayout, QVBoxLayout, QWidget, QLabel, - QLineEdit, QComboBox, QDialog, QDialogButtonBox, QMessageBox) - -from utils.config import (editSelectionConfig, editStylesheet) - - -class MessagePopup(QMessageBox): - def __init__(self, title, message, flags=QMessageBox.Ok): - super(QMessageBox, self).__init__( - QMessageBox.NoIcon, title, message, flags) - - -class BasePicker(QWidget): - def __init__(self, parent, tracker, optionLists=[]): - super(QWidget, self).__init__() - self.parent = parent - self.tracker = tracker - - self.layout = QGridLayout(self) - self.layout.setContentsMargins(0, 0, 0, 0) - - _comboBoxList = [] - _labelList = [] - - for i in range(len(optionLists)): - optionList = optionLists[i] - - _comboBoxList.append(QComboBox()) - _comboBoxList[i].addItems(optionList) - self.layout.addWidget(_comboBoxList[i], i, 1) - _labelList.append(QLabel("")) - self.layout.addWidget(_labelList[i], i, 0) - - self.pickTop = _comboBoxList[0] - self.pickBot = _comboBoxList[-1] - self.nameTop = _labelList[0] - self.nameBot = _labelList[-1] - - def applySelections(self, selections): - for selection in selections: - index = getattr(self, f"{selection}Index") - self.parent.config["SELECTED_INDEX"][selection] = index - editSelectionConfig(index, selection) - - -class LanguagePicker(BasePicker): - def __init__(self, parent, tracker): - config = parent.config - listTop = config["LANGUAGE"] - listBot = config["ORIENTATION"] - optionLists = [listTop, listBot] - - super().__init__(parent, tracker, optionLists) - self.pickTop.currentIndexChanged.connect(self.changeLanguage) - self.pickTop.setCurrentIndex(config["SELECTED_INDEX"]["language"]) - self.nameTop.setText("Language: ") - self.pickBot.currentIndexChanged.connect(self.changeOrientation) - self.pickBot.setCurrentIndex(config["SELECTED_INDEX"]["orientation"]) - self.nameBot.setText("Orientation: ") - - self.languageIndex = self.pickTop.currentIndex() - self.orientationIndex = self.pickBot.currentIndex() - - def changeLanguage(self, i): - self.languageIndex = i - selectedLanguage = self.pickTop.currentText().strip() - if selectedLanguage == "Japanese": - self.tracker.language = "jpn" - if selectedLanguage == "Korean": - self.tracker.language = "kor" - if selectedLanguage == "Chinese SIM": - self.tracker.language = "chi_sim" - if selectedLanguage == "Chinese TRA": - self.tracker.language = "chi_tra" - if selectedLanguage == "English": - self.tracker.language = "eng" - - def changeOrientation(self, i): - self.orientationIndex = i - selectedOrientation = self.pickBot.currentText().strip() - if selectedOrientation == "Vertical": - self.tracker.orientation = "_vert" - if selectedOrientation == "Horizontal": - self.tracker.orientation = "" - - def applyChanges(self): - self.applySelections(['language', 'orientation']) - return True - - -class FontPicker(BasePicker): - def __init__(self, parent, tracker): - config = parent.config - listTop = config["FONT_STYLE"] - listBot = config["FONT_SIZE"] - optionLists = [listTop, listBot] - - super().__init__(parent, tracker, optionLists) - self.pickTop.currentIndexChanged.connect(self.changeFontStyle) - self.pickTop.setCurrentIndex(config["SELECTED_INDEX"]["fontStyle"]) - self.nameTop.setText("Font Style: ") - self.pickBot.currentIndexChanged.connect(self.changeFontSize) - self.pickBot.setCurrentIndex(config["SELECTED_INDEX"]["fontSize"]) - self.nameBot.setText("Font Size: ") - - self.fontStyleText = " font-family: 'Poppins';\n" - self.fontSizeText = " font-size: 16pt;\n" - self.fontStyleIndex = self.pickTop.currentIndex() - self.fontSizeIndex = self.pickBot.currentIndex() - - def changeFontStyle(self, i): - self.fontStyleIndex = i - selectedFontStyle = self.pickTop.currentText().strip() - replacementText = f" font-family: '{selectedFontStyle}';\n" - self.fontStyleText = replacementText - - def changeFontSize(self, i): - self.fontSizeIndex = i - selectedFontSize = int(self.pickBot.currentText().strip()) - replacementText = f" font-size: {selectedFontSize}pt;\n" - self.fontSizeText = replacementText - - def applyChanges(self): - self.applySelections(['fontStyle', 'fontSize']) - editStylesheet(41, self.fontStyleText) - editStylesheet(42, self.fontSizeText) - return True - - -class ScaleImagePicker(BasePicker): - def __init__(self, parent, tracker): - config = parent.config - listTop = config["IMAGE_SCALING"] - optionLists = [listTop] - - super().__init__(parent, tracker, optionLists) - self.pickTop.currentIndexChanged.connect(self.changeScaling) - self.pickTop.setCurrentIndex(config["SELECTED_INDEX"]["imageScaling"]) - self.nameTop.setText("Image Scaling: ") - - self.imageScalingIndex = self.pickTop.currentIndex() - - def changeScaling(self, i): - self.imageScalingIndex = i - - def applyChanges(self): - self.applySelections(['imageScaling']) - self.parent.canvas.setViewImageMode(self.imageScalingIndex) - return True - - -class ShortcutPicker(BasePicker): - def __init__(self, parent, tracker): - config = parent.config - listTop = config["MODIFIER"] - optionLists = [listTop] - - super().__init__(parent, tracker, optionLists) - self.pickTop.currentIndexChanged.connect(self.changeModifier) - self.pickTop.setCurrentIndex(config["SELECTED_INDEX"]["modifier"]) - self.nameTop.setText("Modifier: ") - - self.pickBot = QLineEdit(config["SHORTCUT"]["captureExternalKey"]) - self.layout.addWidget(self.pickBot, 1, 1) - self.nameBot = QLabel("Key: ") - self.layout.addWidget(self.nameBot, 1, 0) - - self.modifierIndex = self.pickTop.currentIndex() - - def keyInvalidError(self): - MessagePopup( - "Invalid Key", - "Please select an alphanumeric key." - ).exec() - - def changeModifier(self, i): - self.modifierIndex = i - - def setShortcut(self, keyName, modifierText, keyText): - - tooltip = f"{self.parent.config['SHORTCUT'][f'{keyName}Tip']}{modifierText}{keyText}." - self.parent.config["SHORTCUT"][keyName] = f"{modifierText}{keyText}" - self.parent.config["SHORTCUT"][f"{keyName}Key"] = keyText - self.parent.config["TBAR_FUNCS"]["FILE"][f"{keyName}Helper"]["helpMsg"] = tooltip - - def applyChanges(self): - selectedModifier = self.pickTop.currentText().strip() + "+" - if selectedModifier == "No Modifier+": - selectedModifier = "" - - if not self.pickBot.text().isalnum(): - self.keyInvalidError() - return False - if len(self.pickBot.text()) != 1: - self.keyInvalidError() - return False - - self.setShortcut('captureExternal', selectedModifier, - self.pickBot.text()) - self.applySelections(['modifier']) - return True - - -class PickerPopup(QDialog): - def __init__(self, widget): - super(QDialog, self).__init__(None, - Qt.WindowCloseButtonHint | Qt.WindowSystemMenuHint | Qt.WindowTitleHint) - self.widget = widget - self.setLayout(QVBoxLayout()) - self.layout().addWidget(widget) - self.buttonBox = QDialogButtonBox( - QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - self.layout().addWidget(self.buttonBox) - - self.buttonBox.rejected.connect(self.cancelClickedEvent) - self.buttonBox.accepted.connect(self.accept) - self.buttonBox.rejected.connect(self.reject) - - def accept(self): - if self.widget.applyChanges(): - return super().accept() - - def cancelClickedEvent(self): - self.close() diff --git a/code/Ribbon.py b/code/Ribbon.py deleted file mode 100644 index 73c6669..0000000 --- a/code/Ribbon.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Poricom Ribbon Components - -Copyright (C) `2021-2022` `` - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" - -from os.path import exists - -from PyQt5.QtGui import (QIcon) -from PyQt5.QtCore import (Qt, QSize) -from PyQt5.QtWidgets import ( - QGridLayout, QHBoxLayout, QWidget, QTabWidget, QPushButton) - -from utils.config import config - - -class RibbonTab(QWidget): - - def __init__(self, parent=None, funcs=None, tracker=None, tabName=""): - super(QWidget, self).__init__() - self.parent = parent - self.tracker = tracker - self.tabName = tabName - - self.buttonList = [] - self.layout = QHBoxLayout(self) - self.layout.setAlignment(Qt.AlignLeft) - - self.initButtons(funcs) - - def initButtons(self, funcs): - - for funcName, funcConfig in funcs.items(): - self.loadButtonConfig(funcName, funcConfig) - self.layout.addWidget(self.buttonList[-1], - alignment=getattr(Qt, funcConfig["align"])) - self.layout.addStretch() - self.layout.addWidget(PageNavigator(self.parent)) - - def loadButtonConfig(self, buttonName, buttonConfig): - - w = self.parent.frameGeometry().height( - )*config["TBAR_ISIZE_REL"]*buttonConfig["iconW"] - h = self.parent.frameGeometry().height( - )*config["TBAR_ISIZE_REL"]*buttonConfig["iconH"] - m = config["TBAR_ISIZE_MARGIN"] - - icon = QIcon() - path = config["TBAR_ICONS"] + buttonConfig["path"] - if (exists(path)): - icon = QIcon(path) - else: - icon = QIcon(config["TBAR_ICON_DEFAULT"]) - - self.buttonList.append(QPushButton(self)) - self.buttonList[-1].setObjectName(buttonName) - - self.buttonList[-1].setIcon(icon) - self.buttonList[-1].setIconSize(QSize(w, h)) - self.buttonList[-1].setFixedSize(QSize(w*m, h*m)) - - tooltip = f"

{buttonConfig['helpTitle']}\ -

{buttonConfig['helpMsg']}

" - self.buttonList[-1].setToolTip(tooltip) - self.buttonList[-1].setCheckable(buttonConfig["toggle"]) - - if hasattr(self.parent, buttonName): - self.buttonList[-1].clicked.connect( - getattr(self.parent, buttonName)) - else: - self.buttonList[-1].clicked.connect( - getattr(self.parent, 'poricomNoop')) - - -class PageNavigator(RibbonTab): - - def __init__(self, parent=None, tracker=None): - super(QWidget, self).__init__() - self.parent = parent - self.tracker = tracker - self.buttonList = [] - - self.layout = QGridLayout(self) - self.layout.setContentsMargins(0, 0, 0, 0) - for funcName, funcConfig in config["MODE_FUNCS"].items(): - self.loadButtonConfig(funcName, funcConfig) - - self.layout.addWidget(self.buttonList[0], 0, 0, 1, 1) - self.layout.addWidget(self.buttonList[1], 1, 0, 1, 1) - self.layout.addWidget(self.buttonList[2], 0, 1, 1, 2) - self.layout.addWidget(self.buttonList[3], 1, 1, 1, 1) - self.layout.addWidget(self.buttonList[4], 1, 2, 1, 1) - - -class Ribbon(QTabWidget): - def __init__(self, parent=None, tracker=None): - super(QTabWidget, self).__init__(parent) - self.parent = parent - self.tracker = tracker - - h = self.parent.frameGeometry().height( - ) * config["TBAR_ISIZE_REL"] * config["RBN_HEIGHT"] - self.setFixedHeight(h) - - for tabName, tools in config["TBAR_FUNCS"].items(): - self.addTab(RibbonTab(parent=self.parent, funcs=tools, - tracker=self.tracker, tabName=tabName), tabName) diff --git a/code/Trackers.py b/code/Trackers.py deleted file mode 100644 index aebbee7..0000000 --- a/code/Trackers.py +++ /dev/null @@ -1,176 +0,0 @@ -""" -Poricom State-Tracking Logic - -Copyright (C) `2021-2022` `` - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" - -from os.path import isfile, join, splitext, normpath, abspath, exists, dirname -from os import listdir - -from PyQt5.QtGui import QPixmap, QPainter - -from utils.config import config - - -class Tracker: - - def __init__(self, filename=config["HOME_IMAGE"], filenext=config["ABOUT_IMAGE"]): - if not config["SPLIT_VIEW_MODE"]: - self._pixImage = PImage(filename) - if config["SPLIT_VIEW_MODE"]: - splitImage = self.twoFileToImage(filename, filenext) - self._pixImage = PImage(splitImage, filename) - self._pixMask = PImage(filename) - - self._filepath = abspath(dirname(filename)) - self._writeMode = False - - self._imageList = [] - - self._language = "jpn" - self._orientation = "_vert" - - self._betterOCR = False - self._ocrModel = None - - def twoFileToImage(self, fileLeft, fileRight): - imageLeft, imageRight = PImage(fileLeft), PImage(fileRight) - if not (imageLeft.isValid()): - return - - w = imageLeft.width() + imageRight.width() - h = max(imageLeft.height(), imageRight.height()) - if imageRight.isNull(): - w = imageLeft.width() * 2 - h = imageLeft.height() - splitImage = QPixmap(w, h) - painter = QPainter(splitImage) - painter.drawPixmap(0, 0, imageLeft.width(), imageLeft.height(), - imageLeft) - painter.drawPixmap(imageLeft.width(), 0, imageRight.width(), - imageRight.height(), imageRight) - painter.end() - - return splitImage - - @property - def pixImage(self): - return self._pixImage - - @pixImage.setter - def pixImage(self, image): - if (type(image) is str and PImage(image).isValid()): - self._pixImage = PImage(image) - self._pixImage.filename = abspath(image) - self._filepath = abspath(dirname(image)) - if (type(image) is tuple): - fileLeft, fileRight = image - if not fileRight: - if fileLeft: - self._pixImage = PImage(fileLeft) - self._pixImage.filename = abspath(fileLeft) - self._filepath = abspath(dirname(fileLeft)) - return - splitImage = self.twoFileToImage(fileLeft, fileRight) - - self._pixImage = PImage(splitImage, fileLeft) - self._pixImage.filename = abspath(fileLeft) - self._filepath = abspath(dirname(fileLeft)) - - @property - def pixMask(self): - return self._pixMask - - @pixMask.setter - def pixMask(self, image): - self._pixMask = image - - @property - def filepath(self): - return self._filepath - - @filepath.setter - def filepath(self, filepath): - self._filepath = filepath - filelist = filter(lambda f: isfile(join(self.filepath, - f)), listdir(self.filepath)) - self._imageList = list(map(lambda p: normpath(join(self.filepath, p)), filter( - (lambda f: ('*'+splitext(f)[1]) in config["IMAGE_EXTENSIONS"]), filelist))) - - @property - def language(self): - return self._language - - @language.setter - def language(self, language): - self._language = language - - @property - def orientation(self): - return self._orientation - - @orientation.setter - def orientation(self, orientation): - self._orientation = orientation - - @property - def ocrModel(self): - return self._ocrModel - - @ocrModel.setter - def ocrModel(self, ocrModel): - self._ocrModel = ocrModel - - @property - def writeMode(self): - return self._writeMode - - @writeMode.setter - def writeMode(self, writeMode): - self._writeMode = writeMode - - def switchWriteMode(self): - self._writeMode = not self._writeMode - return self._writeMode - - def switchOCRMode(self): - self._betterOCR = not self._betterOCR - return self._betterOCR - - -class PImage(QPixmap): - - def __init__(self, *args): - super(QPixmap, self).__init__(args[0]) - - # Current directory + filename - if type(args[0]) == str: - self._filename = args[0] - if type(args[0]) == QPixmap: - self._filename = args[1] - # Current directory - self._filepath = None - - @property - def filename(self): - return self._filename - - @filename.setter - def filename(self, filename): - self._filename = filename - - def isValid(self): - return exists(self._filename) and isfile(self._filename) diff --git a/code/Views.py b/code/Views.py deleted file mode 100644 index bef47b1..0000000 --- a/code/Views.py +++ /dev/null @@ -1,267 +0,0 @@ -""" -Poricom View Components - -Copyright (C) `2021-2022` `` - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" - -from time import sleep - -from PyQt5.QtCore import (Qt, QRectF, QTimer, QThreadPool, pyqtSlot) -from PyQt5.QtCore import (Qt, QRect, QSize, QRectF, - QTimer, QThreadPool, pyqtSlot) -from PyQt5.QtWidgets import ( - QApplication, QGraphicsView, QGraphicsScene, QLabel) - -from Workers import BaseWorker -from utils.image_io import logText, pixboxToText - - -class BaseCanvas(QGraphicsView): - - def __init__(self, parent=None, tracker=None): - super(QGraphicsView, self).__init__(parent) - self.parent = parent - self.tracker = tracker - - self.timer_ = QTimer() - self.timer_.setInterval(300) - self.timer_.setSingleShot(True) - self.timer_.timeout.connect(self.rubberBandStopped) - - self.canvasText = QLabel("", self, Qt.WindowStaysOnTopHint) - self.canvasText.setWordWrap(True) - self.canvasText.hide() - self.canvasText.setObjectName("canvasText") - - self.scene = QGraphicsScene() - self.setScene(self.scene) - self.pixmap = self.scene.addPixmap(self.tracker.pixImage.scaledToWidth( - self.viewport().geometry().width(), Qt.SmoothTransformation)) - - self.setDragMode(QGraphicsView.RubberBandDrag) - - def mouseMoveEvent(self, event): - rubberBandVisible = not self.rubberBandRect().isNull() - if (event.buttons() & Qt.LeftButton) and rubberBandVisible: - self.timer_.start() - QGraphicsView.mouseMoveEvent(self, event) - - def mouseReleaseEvent(self, event): - logPath = self.tracker.filepath + "/log.txt" - logToFile = self.tracker.writeMode - text = self.canvasText.text() - logText(text, mode=logToFile, path=logPath) - self.canvasText.hide() - super().mouseReleaseEvent(event) - - @pyqtSlot() - def rubberBandStopped(self): - - if (self.canvasText.isHidden()): - self.canvasText.setText("") - self.canvasText.adjustSize() - self.canvasText.show() - - lang = self.tracker.language + self.tracker.orientation - pixbox = self.grab(self.rubberBandRect()) - - worker = BaseWorker(pixboxToText, pixbox, lang, self.tracker.ocrModel) - worker.signals.result.connect(self.canvasText.setText) - worker.signals.finished.connect(self.canvasText.adjustSize) - self.timer_.timeout.disconnect(self.rubberBandStopped) - worker.signals.finished.connect( - lambda: self.timer_.timeout.connect(self.rubberBandStopped)) - QThreadPool.globalInstance().start(worker) - - -class FullScreen(BaseCanvas): - - def __init__(self, parent=None, tracker=None): - super().__init__(parent, tracker) - self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - - def takeScreenshot(self): - screen = QApplication.primaryScreen() - s = screen.size() - self.pixmap.setPixmap(screen.grabWindow( - 0).scaled(s.width(), s.height())) - self.scene.setSceneRect(QRectF(self.pixmap.pixmap().rect())) - - def mouseReleaseEvent(self, event): - BaseCanvas.mouseReleaseEvent(self, event) - self.parent.close() - - -class OCRCanvas(BaseCanvas): - - def __init__(self, parent=None, tracker=None): - super().__init__(parent, tracker) - - self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - - self._viewImageMode = parent.config["VIEW_IMAGE_MODE"] - self._splitViewMode = parent.config["SPLIT_VIEW_MODE"] - self._zoomPanMode = False - self.currentScale = 1 - - self._scrollAtMin = 0 - self._scrollAtMax = 0 - self._trackPadAtMin = 0 - self._trackPadAtMax = 0 - self._scrollSuppressed = False - - self.scene = QGraphicsScene() - self.setScene(self.scene) - self.pixmap = self.scene.addPixmap(self.tracker.pixImage.scaledToWidth( - self.viewport().geometry().width(), Qt.SmoothTransformation)) - - def viewImage(self, factor=1): - # self.verticalScrollBar().setSliderPosition(0) - factor = self.currentScale - w = factor*self.viewport().geometry().width() - h = factor*self.viewport().geometry().height() - if self._viewImageMode == 0: - self.pixmap.setPixmap( - self.tracker.pixImage.scaledToWidth(w, Qt.SmoothTransformation)) - elif self._viewImageMode == 1: - self.pixmap.setPixmap( - self.tracker.pixImage.scaledToHeight(h, Qt.SmoothTransformation)) - elif self._viewImageMode == 2: - self.pixmap.setPixmap(self.tracker.pixImage.scaled( - w, h, Qt.KeepAspectRatio, Qt.SmoothTransformation)) - self.scene.setSceneRect(QRectF(self.pixmap.pixmap().rect())) - - def setViewImageMode(self, mode): - self._viewImageMode = mode - self.parent.config["VIEW_IMAGE_MODE"] = mode - self.parent.config["SELECTED_INDEX"]['imageScaling'] = mode - self.viewImage() - - def splitViewMode(self): - return self._splitViewMode - - def toggleSplitView(self): - self._splitViewMode = not self._splitViewMode - self.parent.config["SPLIT_VIEW_MODE"] = self._splitViewMode - - def zoomView(self, isZoomIn, usingButton=False): - factor = 1.1 - if usingButton: - factor = 1.4 - - if isZoomIn and self.currentScale < 15: - #self.scale(factor, factor) - self.currentScale *= factor - self.viewImage(self.currentScale) - elif not isZoomIn and self.currentScale > 0.35: - #self.scale(1/factor, 1/factor) - self.currentScale /= factor - self.viewImage(self.currentScale) - - def toggleZoomPanMode(self): - self._zoomPanMode = not self._zoomPanMode - - def resizeEvent(self, event): - self.viewImage() - QGraphicsView.resizeEvent(self, event) - - def wheelEvent(self, event): - pressedKey = QApplication.keyboardModifiers() - zoomMode = pressedKey == Qt.ControlModifier or self._zoomPanMode - - # self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) - if zoomMode: - if event.angleDelta().y() > 0: - isZoomIn = True - elif event.angleDelta().y() < 0: - isZoomIn = False - scenePos = self.mapToScene(event.pos()) - truePos = QRect(scenePos.toPoint(), QSize(2, 2)).center() - self.centerOn(truePos) - self.zoomView(isZoomIn) - - if self._scrollSuppressed: - return - - if not zoomMode: - - mouseScrollLimit = 3 - trackpadScrollLimit = 36 - wheelDelta = 120 - - def suppressScroll(): - self._scrollSuppressed = True - worker = BaseWorker(sleep, 0.3) - worker.signals.finished.connect( - lambda: setattr(self, "_scrollSuppressed", False)) - QThreadPool.globalInstance().start(worker) - - if (event.angleDelta().y() < 0 and - self.verticalScrollBar().value() == self.verticalScrollBar().maximum()): - if (event.angleDelta().y() > -wheelDelta): - if (self._trackPadAtMax == trackpadScrollLimit): - self.parent.loadNextImage() - self._trackPadAtMax = 0 - suppressScroll() - return - else: - self._trackPadAtMax += 1 - elif (event.angleDelta().y() <= -wheelDelta): - if (self._scrollAtMax == mouseScrollLimit): - self.parent.loadNextImage() - self._scrollAtMax = 0 - suppressScroll() - return - else: - self._scrollAtMax += 1 - - if (event.angleDelta().y() > 0 and - self.verticalScrollBar().value() == self.verticalScrollBar().minimum()): - if (event.angleDelta().y() < wheelDelta): - if (self._trackPadAtMin == trackpadScrollLimit): - self.parent.loadPrevImage() - self._trackPadAtMin = 0 - suppressScroll() - return - else: - self._trackPadAtMin += 1 - elif (event.angleDelta().y() >= wheelDelta): - if (self._scrollAtMin == mouseScrollLimit): - self.parent.loadPrevImage() - self._scrollAtMin = 0 - suppressScroll() - return - else: - self._scrollAtMin += 1 - QGraphicsView.wheelEvent(self, event) - - def mouseMoveEvent(self, event): - pressedKey = QApplication.keyboardModifiers() - panMode = pressedKey == Qt.ControlModifier or self._zoomPanMode - - if panMode: - self.setDragMode(QGraphicsView.ScrollHandDrag) - else: - self.setDragMode(QGraphicsView.RubberBandDrag) - - BaseCanvas.mouseMoveEvent(self, event) - - def mouseDoubleClickEvent(self, event): - self.currentScale = 1 - self.viewImage(self.currentScale) - QGraphicsView.mouseDoubleClickEvent(self, event) diff --git a/code/old/config.py b/code/old/config.py deleted file mode 100644 index 604307c..0000000 --- a/code/old/config.py +++ /dev/null @@ -1,219 +0,0 @@ -""" -Poricom -Copyright (C) `2021-2022` `` - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" - -stylesheet_path = './assets/styles.qss' -combobox_selected_index = { - 'language': 0, - 'orientation': 0, - 'font_style': 0, - 'font_size': 2, -} -picker_index = { - 'language': 20, - 'orientation': 21, - 'font_style': 22, - 'font_size': 23 -} -cfg = { - "IMAGE_EXTENSIONS": ["*.bmp", "*.gif", "*.jpg", "*.jpeg", "*.png", - "*.pbm", "*.pgm", "*.ppm", "*.webp", "*.xbm", "*.xpm"], - - "LANGUAGE": [" Japanese", " Korean", " Chinese SIM", " Chinese TRA ", " English"], - "ORIENTATION": [" Vertical", " Horizontal"], - "LANG_PATH": "./assets/languages/", - - "FONT_STYLE": [" Poppins", " Arial", " Verdana", " Helvetica", " Times New Roman"], - "FONT_SIZE": [" 12", " 14", " 16", " 20", " 24", " 32", " 40", " 56", " 72"], - - "SELECTED_INDEX": combobox_selected_index, - "PICKER_INDEX": picker_index, - - "STYLES_PATH": "./assets/", - "STYLES_DEFAULT": stylesheet_path, - - "NAV_VIEW_RATIO": [3,11], - "NAV_ROOT": "./assets/images/", - - "NAV_FUNCS": { - "path_changed": "view_image_from_fdialog", - "nav_clicked": "view_image_from_explorer" - }, - - "LOGO": "./assets/images/icons/logo.ico", - "HOME_IMAGE": "./assets/images/home.png", - - "RBN_HEIGHT": 2.4, - - "TBAR_ISIZE_REL": 0.1, - "TBAR_ISIZE_MARGIN": 1.3, - - "TBAR_ICONS": "./assets/images/icons/", - "TBAR_ICONS_LIGHT": "./assets/images/icons/", - "TBAR_ICON_DEFAULT": "./assets/images/icons/default_icon.png", - - "TBAR_FUNCS": { - "FILE": { - "open_dir": { - "help_title": "Open manga directory", - "help_msg": "Open a directory containing images.", - "path": "open_dir.png", - "toggle": False, - "align": "AlignLeft", - "icon_h": 1.0, - "icon_w": 1.0 - }, - "open_manga": { - "help_title": "Open manga file", - "help_msg": "Supports the following formats: cbr, cbz, pdf.", - "path": "open_manga.png", - "toggle": False, - "align": "AlignLeft", - "icon_h": 1.0, - "icon_w": 1.0 - } - }, - "VIEW": { - "toggle_stylesheet": { - "help_title": "Change theme", - "help_msg": "Switch between light and dark mode.", - "path": "toggle_stylesheet.png", - "toggle": False, - "align": "AlignLeft", - "icon_h": 1.0, - "icon_w": 1.0 - }, - "modify_font_settings": { - "help_title": "Modify preview text", - "help_msg": "Change font style and font size of preview text.", - "path": "modify_font_settings.png", - "toggle": False, - "align": "AlignLeft", - "icon_h": 1.0, - "icon_w": 1.0 - }, - "fit_horizontally": { - "help_title": "Fit image horizontally", - "help_msg": "", - "path": "fit_horizontally.png", - "toggle": True, - "align": "AlignLeft", - "icon_h": 1.0, - "icon_w": 1.0 - }, - "fit_vertically": { - "help_title": "Fit image vertically", - "help_msg": "", - "path": "fit_vertically.png", - "toggle": True, - "align": "AlignLeft", - "icon_h": 1.0, - "icon_w": 1.0 - } - }, - "SETTINGS": { - "load_model": { - "help_title": "Switch detection model", - "help_msg": "Switch between MangaOCR and Tesseract models.", - "path": "load_model.png", - "toggle": True, - "align": "AlignLeft", - "icon_h": 1.0, - "icon_w": 1.0 - }, - "modify_tesseract": { - "help_title": "Tesseract settings", - "help_msg": "Set the language and orientation for the \ - Tesseract model.", - "path": "modify_tesseract.png", - "toggle": False, - "align": "AlignLeft", - "icon_h": 1.0, - "icon_w": 1.0 - }, - "toggle_logging": { - "help_title": "Enable text logging", - "help_msg": "Save detected text to a text file located in the \ - current project directory.", - "path": "toggle_logging.png", - "toggle": True, - "align": "AlignLeft", - "icon_h": 1.0, - "icon_w": 1.0 - }, - "toggle_mouse_mode": { - "help_title": "Change mouse behavior", - "help_msg": "This will disable text detection. Turn this on \ - only if do not want to hold CTRL key to zoom and pan \ - on an image.", - "path": "toggle_mouse_mode.png", - "toggle": True, - "align": "AlignLeft", - "icon_h": 1.0, - "icon_w": 1.0 - } - } - }, - - "MODE_FUNCS": { - "zoom_in": { - "help_title": "Zoom in", - "help_msg": "Hint: Double click the image to reset zoom.", - "path": "zoom_in.png", - "toggle": False, - "align": "AlignRight", - "icon_h": 0.45, - "icon_w": 0.45 - }, - "zoom_out": { - "help_title": "Zoom out", - "help_msg": "Hint: Double click the image to reset zoom.", - "path": "zoom_out.png", - "toggle": False, - "align": "AlignRight", - "icon_h": 0.45, - "icon_w": 0.45 - }, - "load_image_at_idx": { - "help_title": "", - "help_msg": "Jump to page", - "path": "load_image_at_idx.png", - "toggle": False, - "align": "AlignRight", - "icon_h": 0.45, - "icon_w": 1.3 - }, - "load_prev_image": { - "help_title": "", - "help_msg": "Show previous image", - "path": "load_prev_image.png", - "toggle": False, - "align": "AlignRight", - "icon_h": 0.45, - "icon_w": 0.6 - }, - "load_next_image": { - "help_title": "", - "help_msg": "Show next image", - "path": "load_next_image.png", - "toggle": False, - "align": "AlignRight", - "icon_h": 0.45, - "icon_w": 0.6 - } - } -} \ No newline at end of file diff --git a/code/old/memory.py b/code/old/memory.py deleted file mode 100644 index fc4781f..0000000 --- a/code/old/memory.py +++ /dev/null @@ -1,62 +0,0 @@ -# TODO: Rewrite this as a Tracker object that will -# save image paths, pixmaps of original images and -# masks, button states, and window state -# Use decorators - -class Tracker: - pass - -from default import cfg -from os import listdir -from os.path import isfile, join, splitext, normpath - -img_index = 0 -img_paths = [] -mask_paths = [] -curr_dir = cfg["NAV_ROOT"] -curr_img = cfg["HOME_IMAGE"] - -def get_img_path(): - #global curr_dir - return curr_dir - -def set_img_path(path): - global curr_dir, img_paths - curr_dir = normpath(path) - filelist = filter(lambda f: isfile(join(path, f)), listdir(path)) - img_paths = list(map(lambda p: normpath(join(path, p)), - filter((lambda f: ('*'+splitext(f)[1]) in - cfg["IMAGE_EXTENSIONS"]), filelist))) - -def get_img_list(): - #global curr_dir - return img_paths - -def get_curr_img(): - #global curr_img - return curr_img - -def get_prev_img(): - pass - -def set_curr_img(filepath): - global curr_img - curr_img = filepath - -def set_prev_img(): - pass - -def get_curr_mask(): - pass - -def get_prev_mask(): - pass - -def set_curr_mask(): - pass - -def set_prev_mask(): - pass - -def get_img_index(): - pass \ No newline at end of file diff --git a/code/old/ribbon.py b/code/old/ribbon.py deleted file mode 100644 index 8bf17ca..0000000 --- a/code/old/ribbon.py +++ /dev/null @@ -1,120 +0,0 @@ -from PyQt5.QtWidgets import (QMainWindow, QApplication, QPushButton, - QWidget, QAction, QTabWidget, QVBoxLayout, QGridLayout, - QLabel, QHBoxLayout, QFileDialog) - -from PyQt5.QtGui import QIcon -from PyQt5.QtCore import QSize, Qt, pyqtSignal, pyqtSlot - -import memory as mem -from viewer import ImageViewer, ImageNavigator - -from default import cfg -from os.path import exists - -class PMainWindow(QWidget): - def __init__(self, parent=None): - super(QWidget, self).__init__(parent) - self.layout = QVBoxLayout(self) - - self.ribbon = QTabWidget() - self.createRibbon() - self.layout.addWidget(self.ribbon) - - self.img_viewer = ImageNavigator() - self.layout.addWidget(self.img_viewer) - - def createRibbon(self): - h = self.frameGeometry().height() * cfg["TBAR_ISIZE_REL"] * cfg["RBN_HEIGHT"] - self.ribbon.setFixedHeight(h) - for tab_name, tools in cfg["TBAR_FUNCS"].items(): - self.ribbon.addTab(Toolbar(parent=self, fxns=tools), tab_name) - - def update_window(self, mode=0): - self.img_viewer.set_proj_path(mem.get_img_path()) - - def open_dir(self): - path = str(QFileDialog.getExistingDirectory(self, "Select Directory")) - if path: - mem.set_img_path(path) - self.update_window() - - def save_img(self): - print("Image saved to ", mem.get_img_path()) - pass - - def delete_img(self): - print("Image deleted in ", mem.get_img_path()) - pass - - def get_mask(self): - print("Generating mask for ", mem.get_img_path()) - pass - - def delete_text(self): - print("Text deleted from mask ", mem.get_img_path()) - pass - - def edit_mask_(self): - #TODO - pass - - def edit_mask(self): - # Connect the trigger signal to a slot. - self.img_viewer.some_signal.connect(self.handle_trigger) - - # Emit the signal. - self.img_viewer.some_signal.emit("1") - - @pyqtSlot(str) - def handle_trigger(self, r): - #print("I got a signal" + r) - pass - - def compare_img(self): - #self.img_viewer.mask_viewer.setHidden( - # not self.img_viewer.mask_viewer.isHidden()) - pass - -class Toolbar(QWidget): - - acquire_index = pyqtSignal(int) - - def __init__(self, parent=None, fxns=None): - super(QWidget, self).__init__() - self.layout = QHBoxLayout(self) - self.buttons = [] - self.parent = parent - - s = self.parent.frameGeometry().height() * cfg["TBAR_ISIZE_REL"] - m = s * cfg["TBAR_ISIZE_MARGIN"] - self.layout.setAlignment(Qt.AlignLeft) - count = 0 - for fxn in fxns: - icon = QIcon() - path = cfg["TBAR_IMG_ASSETS"] + fxn + ".png" - if (exists(path)): - icon = QIcon(path) - else: icon = QIcon(cfg["TBAR_ICON_IMG"]) - - self.buttons.append(QPushButton(self)) - self.buttons[-1].setIcon(icon) - self.buttons[-1].setIconSize(QSize(s,s)) - self.buttons[-1].setFixedSize(QSize(m,m)) - #if count == 0: - # self.buttons.append(QPushButton(self)) - # self.buttons[-1].setIcon(QIcon("../assets/images/" + fxn + ".png")) - # self.buttons[-1].setIconSize(QSize(s,s)) - # self.buttons[-1].setFixedSize(QSize(m,m)) - #r = cfg[fxn]["button_code"] - #print(r) - #self.buttons[-1].clicked.connect(lambda s=r: self.on_click(s)) - #count += 1 - #else: - # self.buttons.append(QPushButton("", self)) - # self.buttons[-1].setFixedSize(QSize(m,m)) - self.buttons[-1].clicked.connect(getattr(self.parent, fxn)) - self.layout.addWidget(self.buttons[-1]) - - @pyqtSlot(str) - def on_click(self, index): - print("hahahaha", index) \ No newline at end of file diff --git a/code/old/viewer.py b/code/old/viewer.py deleted file mode 100644 index 5a8048c..0000000 --- a/code/old/viewer.py +++ /dev/null @@ -1,116 +0,0 @@ -from PyQt5.QtCore import Qt, QDir, pyqtSignal, pyqtSlot -from PyQt5.QtGui import (QImage, QPixmap) -from PyQt5.QtWidgets import (QLabel, QScrollArea, QSizePolicy) -from PyQt5.QtWidgets import (QWidget, QFileSystemModel, QTreeView, - QHBoxLayout) - -import memory as mem -from default import cfg - -class ImageNavigator(QWidget): - - some_signal = pyqtSignal(str) - - def __init__(self, parent=None): - super(QWidget, self).__init__(parent) - - _layout = QHBoxLayout(self) - _layout.setContentsMargins(0,0,2,0) - - self.model = QFileSystemModel() - self.init_fs_model() - - self.treeview = QTreeView() - self.treeview.setModel(self.model) - self.init_treeview() - _layout.addWidget(self.treeview, cfg["NAV_VIEW_RATIO"][0]) - - self.image_viewer = ImageViewer() - _layout.addWidget(self.image_viewer, cfg["NAV_VIEW_RATIO"][1]) - - self.mask_viewer = ImageViewer() - _layout.addWidget(self.mask_viewer, cfg["NAV_VIEW_RATIO"][1]) - self.mask_viewer.setHidden(True) - - self.set_proj_path(mem.get_img_path()) - - def init_fs_model(self): - self.model.setFilter(QDir.Files) - self.model.setNameFilterDisables(False) - self.model.setNameFilters(cfg["IMAGE_EXTENSIONS"]) - - self.model.directoryLoaded.connect(self.load_default_img) - #self.model.rootPathChanged.connect(self.set_proj_path) - - def init_treeview(self): - for i in range(1,4): - self.treeview.hideColumn(i) - self.treeview.setIndentation(5) - - self.treeview.clicked.connect(self.view_image_from_explorer) - - def view_image_from_explorer(self, index): - fp = self.model.fileInfo(index).absoluteFilePath() - mem.set_curr_img(fp) - self.image_viewer.view_image() - - def view_image_from_toolbar(self, mode=0): - fp = mem.get_curr_img() - mem.set_curr_img(fp) - self.image_viewer.view_image() - pass - - def set_proj_path(self, path): - if path is None: - #TODO: Error Handling - pass - mem.set_img_path(path) - self.treeview.setRootIndex(self.model.setRootPath(path)) - - def load_default_img(self): - fp = self.model.index(0, 0, self.model.index(self.model.rootPath())) - mem.set_curr_img(self.model.rootPath()+"/"+self.model.data(fp)) - self.image_viewer.view_image() - - - -class ImageViewer(QScrollArea): - def __init__(self, parent = None): - super(QScrollArea, self).__init__(parent) - - self._img_label = QLabel() - self.setWidget(self._img_label) - self.init_img_label() - - self.setWidgetResizable(True) - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - - self.verticalScrollBar().valueChanged.connect(lambda idx: self.kekw(idx)) - - def init_img_label(self): - self._img_label.setContentsMargins(10,10,10,0) - - def view_image(self, filepath=None, q_image=None, mode=0): - - w = self.frameGeometry().width() - h = self.frameGeometry().height() - - filepath = mem.get_curr_img() - image = q_image - if filepath: - image = QImage(filepath) - if image is None: - #TODO: Error Handling - return - pixmap_img = QPixmap.fromImage(image) - self._img_label.setPixmap(pixmap_img.scaledToWidth( - w-20, Qt.SmoothTransformation)) - self._img_label.adjustSize() - - def resizeEvent(self, event): - self.view_image() - QScrollArea.resizeEvent(self, event) - - def kekw(self,idx): - print(idx) \ No newline at end of file diff --git a/code/utils/config.py b/code/utils/config.py deleted file mode 100644 index a283705..0000000 --- a/code/utils/config.py +++ /dev/null @@ -1,53 +0,0 @@ -""" -Poricom Configuration Utilities - -Copyright (C) `2021-2022` `` - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" - -import toml -config = toml.load("./utils/config.toml") - - -def saveOnClose(data, config="utils/config.toml"): - with open(config, 'w') as fh: - toml.dump(data, fh) - - -def editConfig(index, replacementText, config="utils/config.toml"): - data = toml.load(config) - data[index] = replacementText - with open(config, 'w') as fh: - toml.dump(data, fh) - - -def editSelectionConfig(index, cBoxName, config="utils/config.toml"): - data = toml.load(config) - data["SELECTED_INDEX"][cBoxName] = index - with open(config, 'w') as fh: - toml.dump(data, fh) - - -def editStylesheet(index, replacementText): - sheetLight = './assets/styles.qss' - sheetDark = './assets/styles-dark.qss' - with open(sheetLight, 'r') as slFh, open(sheetDark, 'r') as sdFh: - lineLight = slFh.readlines() - linesDark = sdFh.readlines() - lineLight[index] = replacementText - linesDark[index] = replacementText - with open(sheetLight, 'w') as slFh, open(sheetDark, 'w') as sdFh: - slFh.writelines(lineLight) - sdFh.writelines(linesDark) diff --git a/code/utils/config.toml b/code/utils/config.toml deleted file mode 100644 index 7c21217..0000000 --- a/code/utils/config.toml +++ /dev/null @@ -1,240 +0,0 @@ -# Poricom Default Configuration File -# -# Copyright (C) `2021-2022` `` -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -# Lists -IMAGE_EXTENSIONS = [ "*.bmp", "*.gif", "*.jpg", "*.jpeg", "*.png", "*.pbm", "*.pgm", "*.ppm", "*.webp", "*.xbm", "*.xpm",] -LANGUAGE = [ " Japanese", " Korean", " Chinese SIM", " Chinese TRA ", " English",] -ORIENTATION = [ " Vertical", " Horizontal",] -FONT_STYLE = [ " Helvetica", " Poppins", " Arial", " Verdana", " Times New Roman",] -FONT_SIZE = [ " 12", " 14", " 16", " 20", " 24", " 32", " 40", " 56", " 72",] -IMAGE_SCALING= [ " Fit to Width", " Fit to Height", " Fit to Screen",] -MODIFIER = [ " Ctrl", " Shift", " Alt", " Ctrl+Alt", " Shift+Alt", " Shift+Ctrl", " Shift+Alt+Ctrl", " No Modifier"] - -# Filepath -STYLES_PATH = "./assets/" -NAV_ROOT = "./assets/images/" -TBAR_ICONS = "./assets/images/icons/" -TBAR_ICONS_LIGHT = "./assets/images/icons/" -LANG_PATH = "./assets/languages/" - -STYLES_DEFAULT = "./assets/styles.qss" -LOGO = "./assets/images/icons/logo.ico" -HOME_IMAGE = "./assets/images/home.png" -ABOUT_IMAGE = "./assets/images/about.png" -TBAR_ICON_DEFAULT = "./assets/images/icons/default_icon.png" - -# Sizes -RBN_HEIGHT = 2.4 -TBAR_ISIZE_REL = 0.1 -TBAR_ISIZE_MARGIN = 1.3 -NAV_VIEW_RATIO = [ 3, 11,] - -# Mode -VIEW_IMAGE_MODE = 0 -SPLIT_VIEW_MODE = false - -[SELECTED_INDEX] -language = 0 -orientation = 0 -fontStyle = 0 -fontSize = 2 -imageScaling = 0 -modifier = 2 - -[PICKER_INDEX] -language = 49 -orientation = 50 -fontStyle = 51 -fontSize = 52 -imageScaling = 53 -modifier = 54 - -[SHORTCUT] -captureExternal = "Alt+Q" -captureExternalKey = "Q" -captureExternalTip = "This will minimize the app and perform OCR on the current screen. Alternatively, you may use the shortcut: " - -[NAV_FUNCS] -pathChanged = "viewImageFromFDialog" -navClicked = "viewImageFromExplorer" - -# Ribbon buttons (always on) -[MODE_FUNCS] - - [MODE_FUNCS.zoomIn] - helpTitle = "Zoom in" - helpMsg = "Hint: Double click the image to reset zoom." - path = "zoomIn.png" - toggle = false - align = "AlignRight" - iconH = 0.45 - iconW = 0.45 - - [MODE_FUNCS.zoomOut] - helpTitle = "Zoom out" - helpMsg = "Hint: Double click the image to reset zoom." - path = "zoomOut.png" - toggle = false - align = "AlignRight" - iconH = 0.45 - iconW = 0.45 - - [MODE_FUNCS.loadImageAtIndex] - helpTitle = "" - helpMsg = "Jump to page" - path = "loadImageAtIndex.png" - toggle = false - align = "AlignRight" - iconH = 0.45 - iconW = 1.3 - - [MODE_FUNCS.loadPrevImage] - helpTitle = "" - helpMsg = "Show previous image" - path = "loadPrevImage.png" - toggle = false - align = "AlignRight" - iconH = 0.45 - iconW = 0.6 - - [MODE_FUNCS.loadNextImage] - helpTitle = "" - helpMsg = "Show next image" - path = "loadNextImage.png" - toggle = false - align = "AlignRight" - iconH = 0.45 - iconW = 0.6 - -# Ribbon buttons -[TBAR_FUNCS] - - [TBAR_FUNCS.FILE] - - [TBAR_FUNCS.FILE.openDir] - helpTitle = "Open manga directory" - helpMsg = "Open a directory containing images." - path = "openDir.png" - toggle = false - align = "AlignLeft" - iconH = 1.0 - iconW = 1.0 - - [TBAR_FUNCS.FILE.openManga] - helpTitle = "Open manga file" - helpMsg = "Supports the following formats: cbr, cbz, pdf." - path = "openManga.png" - toggle = false - align = "AlignLeft" - iconH = 1.0 - iconW = 1.0 - - [TBAR_FUNCS.FILE.captureExternalHelper] - helpTitle = "External capture" - helpMsg = "This will minimize the app and perform OCR on the current screen. Alternatively, you may use the shortcut Alt+Q." - path = "captureExternalHelper.png" - toggle = false - align = "AlignLeft" - iconH = 1.0 - iconW = 1.0 - - [TBAR_FUNCS.VIEW] - - [TBAR_FUNCS.VIEW.toggleStylesheet] - helpTitle = "Change theme" - helpMsg = "Switch between light and dark mode." - path = "toggleStylesheet.png" - toggle = false - align = "AlignLeft" - iconH = 1.0 - iconW = 1.0 - - [TBAR_FUNCS.VIEW.modifyFontSettings] - helpTitle = "Modify preview text" - helpMsg = "Change font style and font size of preview text." - path = "modifyFontSettings.png" - toggle = false - align = "AlignLeft" - iconH = 1.0 - iconW = 1.0 - - [TBAR_FUNCS.VIEW.toggleSplitView] - helpTitle = "Turn on split view" - helpMsg = "View two images at once." - path = "toggleSplitView.png" - toggle = true - align = "AlignLeft" - iconH = 1.0 - iconW = 1.0 - - [TBAR_FUNCS.VIEW.scaleImage] - helpTitle = "Adjust image scaling" - helpMsg = "Fit an image according to the available options: fit to width, fit to height, fit to screen" - path = "scaleImage.png" - toggle = false - align = "AlignLeft" - iconH = 1.0 - iconW = 1.0 - - [TBAR_FUNCS.CONTROLS] - - [TBAR_FUNCS.CONTROLS.toggleMouseMode] - helpTitle = "Change mouse behavior" - helpMsg = "This will disable text detection. Turn this on only if do not want to hold CTRL key to zoom and pan on an image." - path = "toggleMouseMode.png" - toggle = true - align = "AlignLeft" - iconH = 1.0 - iconW = 1.0 - - [TBAR_FUNCS.CONTROLS.modifyHotkeys] - helpTitle = "Remap hotkeys" - helpMsg = "Change shortcut for external captures." - path = "modifyHotkeys.png" - toggle = false - align = "AlignLeft" - iconH = 1.0 - iconW = 1.0 - - [TBAR_FUNCS.MISC] - - [TBAR_FUNCS.MISC.loadModel] - helpTitle = "Switch detection model" - helpMsg = "Switch between MangaOCR and Tesseract models." - path = "loadModel.png" - toggle = true - align = "AlignLeft" - iconH = 1.0 - iconW = 1.0 - - [TBAR_FUNCS.MISC.modifyTesseract] - helpTitle = "Tesseract settings" - helpMsg = "Set the language and orientation for the Tesseract model." - path = "modifyTesseract.png" - toggle = false - align = "AlignLeft" - iconH = 1.0 - iconW = 1.0 - - [TBAR_FUNCS.MISC.toggleLogging] - helpTitle = "Enable text logging" - helpMsg = "Save detected text to a text file located in the current project directory." - path = "toggleLogging.png" - toggle = true - align = "AlignLeft" - iconH = 1.0 - iconW = 1.0 \ No newline at end of file diff --git a/code/utils/image_io.py b/code/utils/image_io.py deleted file mode 100644 index fe98673..0000000 --- a/code/utils/image_io.py +++ /dev/null @@ -1,96 +0,0 @@ -""" -Poricom Image Processing Utility - -Copyright (C) `2021-2022` `` - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" - -from io import BytesIO -from os.path import splitext, basename -from pathlib import Path - -from PyQt5.QtCore import QBuffer -from PyQt5.QtGui import QGuiApplication -from tesserocr import PyTessBaseAPI -from PIL import Image -import zipfile -import rarfile -import pdf2image - -from utils.config import config - - -def mangaFileToImageDir(filepath): - extractPath, extension = splitext(filepath) - cachePath = f"./poricom_cache/{basename(extractPath)}" - - if extension in [".cbz", ".zip"]: - with zipfile.ZipFile(filepath, 'r') as zipRef: - zipRef.extractall(cachePath) - - rarfile.UNRAR_TOOL = "utils/unrar.exe" - if extension in [".cbr", ".rar"]: - with rarfile.RarFile(filepath) as zipRef: - zipRef.extractall(cachePath) - - if extension in [".pdf"]: - try: - images = pdf2image.convert_from_path(filepath) - except pdf2image.exceptions.PDFInfoNotInstalledError: - images = pdf2image.convert_from_path( - filepath, poppler_path="poppler/Library/bin") - for i in range(len(images)): - filename = basename(extractPath) - Path(cachePath).mkdir(parents=True, exist_ok=True) - images[i].save( - f"{cachePath}/{i+1}_{filename}.png", 'PNG') - - return cachePath - - -def pixboxToText(pixmap, lang="jpn_vert", model=None): - - buffer = QBuffer() - buffer.open(QBuffer.ReadWrite) - pixmap.save(buffer, "PNG") - bytes = BytesIO(buffer.data()) - - if bytes.getbuffer().nbytes == 0: - return - - pillowImage = Image.open(bytes) - text = "" - - if model is not None: - text = model(pillowImage) - - # PSM = 1 works most of the time except on smaller bounding boxes. - # By smaller, we mean textboxes with less text. Usually these - # boxes have at most one vertical line of text. - else: - with PyTessBaseAPI(path=config["LANG_PATH"], lang=lang, oem=1, psm=1) as api: - api.SetImage(pillowImage) - text = api.GetUTF8Text() - - return text.strip() - - -def logText(text, mode=False, path="."): - clipboard = QGuiApplication.clipboard() - clipboard.setText(text) - - if mode: - with open(path, 'a', encoding="utf-8") as fh: - fh.write(text + "\n") diff --git a/environment/base.yaml b/environment/base.yaml new file mode 100644 index 0000000..22e63ef --- /dev/null +++ b/environment/base.yaml @@ -0,0 +1,22 @@ +name: poricom-py39 + +channels: + - conda-forge + - defaults + +dependencies: + - python=3.9 + - pip + - poppler + - tesserocr + - pip: + - argostranslate + - cutlet + - huggingface-hub==0.7.0 + - manga-ocr + - pdf2image + - pillow + - pyqt5 + - pyqtkeybind + - rarfile + - stringcase diff --git a/environment/build.yaml b/environment/build.yaml new file mode 100644 index 0000000..dddabf4 --- /dev/null +++ b/environment/build.yaml @@ -0,0 +1,9 @@ +name: poricom-py39 + +channels: + - conda-forge + - defaults + +dependencies: + - pip: + - pyinstaller==4.10 diff --git a/environment/dev.yaml b/environment/dev.yaml new file mode 100644 index 0000000..78f1ddb --- /dev/null +++ b/environment/dev.yaml @@ -0,0 +1,9 @@ +name: poricom-py39 + +channels: + - conda-forge + - defaults + +dependencies: + - pip: + - black diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index e1de2d8..0000000 --- a/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -pyqt5~=5.15.6 -pillow~=9.0.1 -tesserocr~=2.5.1 -manga-ocr~=0.1.5 -pyqtkeybind~=0.0.9 -rarfile~=4.0 -pdf2image~=1.16.0 \ No newline at end of file