Index: doc/changelog.md ================================================================== --- doc/changelog.md +++ doc/changelog.md @@ -1,9 +1,13 @@ # Changelog # Next version (in development) +## New features + +- Export documents as a lossless PNG image sequence. The export can be triggered via the File menu. + ## Changed features - The main window now opens maximized when starting the application. Added a setting to restore the previous behavior. - Added option to open wizards and dialogs maximized. Off by default. - Reduced click count required for switching printings from 5 to 3. Now, double-clicking editable table cells Index: mtg_proxy_printer/print.py ================================================================== --- mtg_proxy_printer/print.py +++ mtg_proxy_printer/print.py @@ -13,20 +13,34 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . import math +from functools import partial + +try: + from os import process_cpu_count +except ImportError: + from os import cpu_count as process_cpu_count from pathlib import Path +from threading import BoundedSemaphore +import typing -from PyQt5.QtCore import QObject, QMarginsF, QSizeF, pyqtSlot as Slot, QPersistentModelIndex -from PyQt5.QtGui import QPainter, QPdfWriter, QPageSize +from PyQt5.QtCore import QObject, QMarginsF, QSizeF, pyqtSlot as Slot, QPersistentModelIndex, QThreadPool +from PyQt5.QtGui import QPainter, QPdfWriter, QPageSize, QImage from PyQt5.QtPrintSupport import QPrinter + +from mtg_proxy_printer.runner import ProgressSignalContainer +if typing.TYPE_CHECKING: + from mtg_proxy_printer.ui.main_window import MainWindow +from mtg_proxy_printer.units_and_sizes import RESOLUTION import mtg_proxy_printer.meta_data from mtg_proxy_printer.settings import settings from mtg_proxy_printer.model.document import Document from mtg_proxy_printer.model.page_layout import PageLayoutSettings +from mtg_proxy_printer.model.carddb import with_database_write_lock from mtg_proxy_printer.ui.page_scene import RenderMode, PageScene from mtg_proxy_printer.logger import get_logger import mtg_proxy_printer.units_and_sizes logger = get_logger(__name__) del get_logger @@ -35,11 +49,57 @@ __all__ = [ "export_pdf", "create_printer", "Renderer", + "PNGRenderer", ] + +PNGEncoderThreadLimit = BoundedSemaphore(max(1, process_cpu_count()-1)) + + +class PNGRenderer(ProgressSignalContainer): + def __init__(self, main_window: "MainWindow", document: Document, file_path: str): + super().__init__(main_window) + self.document = document + self.file_path = Path(file_path) + self.page_count = document.rowCount() + self.completed = 0 + + def render_document(self): + document = self.document + file_path = self.file_path + page_count = self.page_count + if not page_count: # No pages in document + logger.error("Tried to export a document with zero pages. Aborting.") + self.update_completed.emit() + return + logger.info(f'Exporting document with {document.rowCount()} pages as PNG image sequence to "{file_path}"') + page_size = document.page_layout.to_page_layout(RenderMode.ON_PAPER).pageSize().sizePixels( + round(RESOLUTION.magnitude)) + pool = QThreadPool.globalInstance() + scene = PageScene(document, RenderMode.ON_PAPER, self) + number_width = len(str(page_count)) + parent = file_path.parent + self.begin_update.emit(page_count, self.tr("Export as PNGs")) + for page_nr in range(page_count): + file_name = f"{file_path.stem}-{str(page_nr + 1).zfill(number_width)}.png" + output_path = parent / file_name + image = QImage(page_size, QImage.Format.Format_RGB888) + painter = QPainter(image) + scene.on_current_page_changed(document.index(page_nr, 0)) + scene.render(painter) + pool.start(partial(self._compress_single_image, image, output_path)) + + @with_database_write_lock(PNGEncoderThreadLimit) + def _compress_single_image(self, image: QImage, output_path: Path): + image.save(str(output_path), "PNG", 0) + self.completed += 1 + self.advance_progress.emit() + self.progress.emit(self.completed) + if self.completed == self.page_count: + self.update_completed.emit() def export_pdf(document: Document, file_path: str, parent: QObject = None): pages_to_print = settings["pdf-export"].getint("pdf-page-count-limit") or document.rowCount() if not pages_to_print: # No pages in document. Return now, to avoid dividing by zero Index: mtg_proxy_printer/print_count_updater.py ================================================================== --- mtg_proxy_printer/print_count_updater.py +++ mtg_proxy_printer/print_count_updater.py @@ -42,10 +42,12 @@ or the app would miss writing the data at all, or printing/PDF export had to be prohibited. """ def __init__(self, document: "Document", db: sqlite3.Connection = None): super().__init__() self.db_path = document.card_db.db_path + # Collect the data now, so that the delayed run() does not operate on a potentially modified document, + # but can use the data from the time the document was printed/exported. self.data = document.get_all_card_keys_in_document() self.db_passed_in = bool(db) self._db = db @property @@ -59,11 +61,11 @@ return self._db def run(self): """ Increments the usage count of all cards used in the document and updates the last use timestamps. - Should be called after a successful PDF export and direct printing. + Should be called after a successful PDF/PNG export and direct printing. """ try: self._update_image_usage() finally: self.release_instance() ADDED mtg_proxy_printer/resources/icons/breeze/actions/16/document-export.svg Index: mtg_proxy_printer/resources/icons/breeze/actions/16/document-export.svg ================================================================== --- /dev/null +++ mtg_proxy_printer/resources/icons/breeze/actions/16/document-export.svg @@ -0,0 +1,11 @@ + + + + + + + ADDED mtg_proxy_printer/resources/icons/breeze/actions/22/document-export.svg Index: mtg_proxy_printer/resources/icons/breeze/actions/22/document-export.svg ================================================================== --- /dev/null +++ mtg_proxy_printer/resources/icons/breeze/actions/22/document-export.svg @@ -0,0 +1,11 @@ + + + + + + + ADDED mtg_proxy_printer/resources/icons/breeze/actions/24/document-export.svg Index: mtg_proxy_printer/resources/icons/breeze/actions/24/document-export.svg ================================================================== --- /dev/null +++ mtg_proxy_printer/resources/icons/breeze/actions/24/document-export.svg @@ -0,0 +1,13 @@ + + + + + + + + + ADDED mtg_proxy_printer/resources/icons/breeze/actions/32/document-export.svg Index: mtg_proxy_printer/resources/icons/breeze/actions/32/document-export.svg ================================================================== --- /dev/null +++ mtg_proxy_printer/resources/icons/breeze/actions/32/document-export.svg @@ -0,0 +1,11 @@ + + + + + + + Index: mtg_proxy_printer/resources/resources.qrc ================================================================== --- mtg_proxy_printer/resources/resources.qrc +++ mtg_proxy_printer/resources/resources.qrc @@ -33,10 +33,11 @@ icons/breeze/actions/16/application-exit.svg icons/breeze/actions/16/configure.svg icons/breeze/actions/16/dialog-cancel.svg icons/breeze/actions/16/dialog-ok.svg icons/breeze/actions/16/document-close.svg + icons/breeze/actions/16/document-export.svg icons/breeze/actions/16/document-import.svg icons/breeze/actions/16/document-new.svg icons/breeze/actions/16/document-open.svg icons/breeze/actions/16/document-print-direct.svg icons/breeze/actions/16/document-print-preview.svg @@ -66,10 +67,11 @@ icons/breeze/actions/22/application-exit.svg icons/breeze/actions/22/configure.svg icons/breeze/actions/22/dialog-cancel.svg icons/breeze/actions/22/dialog-ok.svg icons/breeze/actions/22/document-close.svg + icons/breeze/actions/22/document-export.svg icons/breeze/actions/22/document-import.svg icons/breeze/actions/22/document-new.svg icons/breeze/actions/22/document-open.svg icons/breeze/actions/22/document-print-direct.svg icons/breeze/actions/22/document-print-preview.svg @@ -99,10 +101,11 @@ icons/breeze/actions/24/application-exit.svg icons/breeze/actions/24/configure.svg icons/breeze/actions/24/dialog-cancel.svg icons/breeze/actions/24/dialog-ok.svg icons/breeze/actions/24/document-close.svg + icons/breeze/actions/24/document-export.svg icons/breeze/actions/24/document-import.svg icons/breeze/actions/24/document-new.svg icons/breeze/actions/24/document-open.svg icons/breeze/actions/24/document-print-direct.svg icons/breeze/actions/24/document-print-preview.svg @@ -132,10 +135,11 @@ icons/breeze/actions/32/application-exit.svg icons/breeze/actions/32/configure.svg icons/breeze/actions/32/dialog-cancel.svg icons/breeze/actions/32/dialog-ok.svg icons/breeze/actions/32/document-close.svg + icons/breeze/actions/32/document-export.svg icons/breeze/actions/32/document-import.svg icons/breeze/actions/32/document-new.svg icons/breeze/actions/32/document-open.svg icons/breeze/actions/32/document-print-direct.svg icons/breeze/actions/32/document-print.svg Index: mtg_proxy_printer/resources/ui/main_window.ui ================================================================== --- mtg_proxy_printer/resources/ui/main_window.ui +++ mtg_proxy_printer/resources/ui/main_window.ui @@ -35,10 +35,11 @@ + @@ -364,10 +365,21 @@ Add custom cards + + + + + + + Create image sequence + + + Export document as an image sequence + CentralWidget Index: mtg_proxy_printer/ui/dialogs.py ================================================================== --- mtg_proxy_printer/ui/dialogs.py +++ mtg_proxy_printer/ui/dialogs.py @@ -29,10 +29,12 @@ import mtg_proxy_printer.model.imagedb import mtg_proxy_printer.print import mtg_proxy_printer.settings import mtg_proxy_printer.ui.common import mtg_proxy_printer.meta_data +if typing.TYPE_CHECKING: + from mtg_proxy_printer.ui.main_window import MainWindow from mtg_proxy_printer.units_and_sizes import DEFAULT_SAVE_SUFFIX, ConfigParser from mtg_proxy_printer.document_controller.edit_document_settings import ActionEditDocumentSettings from mtg_proxy_printer.print_count_updater import PrintCountUpdater from mtg_proxy_printer.logger import get_logger @@ -49,10 +51,11 @@ logger = get_logger(__name__) del get_logger __all__ = [ "SavePDFDialog", + "SavePNGDialog", "SaveDocumentAsDialog", "LoadDocumentDialog", "AboutDialog", "PrintPreviewDialog", "PrintDialog", @@ -110,10 +113,55 @@ @Slot() def on_reject(self): logger.debug("User aborted saving to PDF. Doing nothing.") + +class SavePNGDialog(QFileDialog): + + def __init__(self, parent: "MainWindow", document: mtg_proxy_printer.model.document.Document): + # Note: Cannot supply already translated strings to __init__, + # because tr() requires to have returned from super().__init__() + super().__init__(parent, "", self.get_preferred_file_name(document)) + self.setWindowTitle(self.tr("Export as PNG")) + self.setNameFilter(self.tr("PNG images (*.png)")) + + if default_path := read_path("pdf-export", "pdf-export-path"): + self.setDirectory(default_path) + self.document = document + self.setAcceptMode(QFileDialog.AcceptMode.AcceptSave) + self.setDefaultSuffix("png") + self.setFileMode(QFileDialog.FileMode.AnyFile) + self.accepted.connect(self.on_accept) + self.rejected.connect(self.on_reject) + logger.info(f"Created {self.__class__.__name__} instance.") + + @staticmethod + def get_preferred_file_name(document: mtg_proxy_printer.model.document.Document): + if document.save_file_path is None: + return "" + # Note: Qt automatically appends the preferred file extension (.png), if the file does not have one. + # So ensure it ends on ".png", if there is a dot in the name. Otherwise, let the user enter the name without + # pre-setting an extension for a cleaner dialog + stem = document.save_file_path.stem + return f"{stem}.png" if "." in stem else stem + + @Slot() + def on_accept(self): + logger.debug("User chose a file name, about to generate the PNG image sequence") + path = self.selectedFiles()[0] + main_window: "MainWindow" = self.parent() + renderer = mtg_proxy_printer.print.PNGRenderer(main_window, self.document, path) + main_window.progress_bars.connect_outer_progress(renderer) + renderer.render_document() + QThreadPool.globalInstance().start(PrintCountUpdater(self.document)) + logger.info(f"Saved document to {path}") + + @Slot() + def on_reject(self): + logger.debug("User aborted exporting to PNG. Doing nothing.") + class LoadSaveDialog(QFileDialog): def __init__(self, *args, **kwargs): # Note: Cannot supply already translated strings to __init__, # because tr() requires to have returned from super().__init__() Index: mtg_proxy_printer/ui/main_window.py ================================================================== --- mtg_proxy_printer/ui/main_window.py +++ mtg_proxy_printer/ui/main_window.py @@ -34,11 +34,11 @@ from mtg_proxy_printer.ui.custom_card_import_dialog import CustomCardImportDialog from mtg_proxy_printer.units_and_sizes import DEFAULT_SAVE_SUFFIX import mtg_proxy_printer.settings import mtg_proxy_printer.print from mtg_proxy_printer.ui.dialogs import SavePDFDialog, SaveDocumentAsDialog, LoadDocumentDialog, \ - AboutDialog, PrintPreviewDialog, PrintDialog, DocumentSettingsDialog + AboutDialog, PrintPreviewDialog, PrintDialog, DocumentSettingsDialog, SavePNGDialog from mtg_proxy_printer.ui.common import show_wizard_or_dialog from mtg_proxy_printer.ui.cache_cleanup_wizard import CacheCleanupWizard from mtg_proxy_printer.ui.deck_import_wizard import DeckImportWizard from mtg_proxy_printer.ui.progress_bar import ProgressBar @@ -191,11 +191,14 @@ ui.central_widget, ui.action_cleanup_local_image_cache, ui.action_print, ui.action_print_pdf, ui.action_print_preview, + ui.action_export_png, ui.action_show_settings, + ui.action_add_custom_cards, + ui.action_download_missing_card_images, ] def _connect_image_database_signals(self, image_db: ImageDatabase): image_db.card_download_starting.connect(self.progress_bars.begin_inner_progress) image_db.card_download_finished.connect(self.progress_bars.end_inner_progress) @@ -302,10 +305,22 @@ return self.current_dialog = dialog = SavePDFDialog(self, self.document) dialog.finished.connect(self.on_dialog_finished) self.missing_images_manager.obtain_missing_images(dialog.open) + @Slot() + def on_action_export_png_triggered(self): + logger.info(f"User exports the current document to a sequence of PNG images.") + action_str = self.tr( + "exporting as a PNG image sequence", + "This is passed as the {action} when asking the user about compacting the document if that can save pages") + if self._ask_user_about_compacting_document(action_str) == StandardButton.Cancel: + return + self.current_dialog = dialog = SavePNGDialog(self, self.document) + dialog.finished.connect(self.on_dialog_finished) + self.missing_images_manager.obtain_missing_images(dialog.open) + @Slot() def on_action_add_empty_card_triggered(self): empty_card = self.document.get_empty_card_for_current_page() action = ActionAddCard(empty_card) self.document.apply(action) Index: mtg_proxy_printer/ui/progress_bar.py ================================================================== --- mtg_proxy_printer/ui/progress_bar.py +++ mtg_proxy_printer/ui/progress_bar.py @@ -15,10 +15,12 @@ from PyQt5.QtCore import pyqtSlot as Slot, Qt from PyQt5.QtWidgets import QWidget, QLabel, QProgressBar +from mtg_proxy_printer.runner import ProgressSignalContainer + try: from mtg_proxy_printer.ui.generated.progress_bar import Ui_ProgressBar except ModuleNotFoundError: from mtg_proxy_printer.ui.common import load_ui_from_file Ui_ProgressBar = load_ui_from_file("progress_bar") @@ -25,10 +27,12 @@ from mtg_proxy_printer.logger import get_logger logger = get_logger(__name__) del get_logger +ConnectionType = Qt.ConnectionType +QueuedConnection = ConnectionType.QueuedConnection __all__ = [ "ProgressBar", ] @@ -44,11 +48,36 @@ for item in (ui.inner_progress_bar, ui.inner_progress_label): self._set_retain_size_policy(item, True) item.hide() for item in (ui.outer_progress_bar, ui.outer_progress_label, ui.independent_bar, ui.independent_label): item.hide() - + + def connect_inner_progress(self, sender: ProgressSignalContainer, con_type: ConnectionType = QueuedConnection): + self._connect_progress_slots( + sender, con_type, + self.begin_inner_progress, self.set_inner_progress, self.end_inner_progress + ) + + def connect_outer_progress(self, sender: ProgressSignalContainer, con_type: ConnectionType = QueuedConnection): + self._connect_progress_slots( + sender, con_type, + self.begin_outer_progress, self.set_outer_progress, self.end_outer_progress + ) + + def connect_independent_progress(self, sender: ProgressSignalContainer, con_type: ConnectionType = QueuedConnection): + self._connect_progress_slots( + sender, con_type, + self.begin_independent_progress, self.set_independent_progress, self.end_independent_progress + ) + + @staticmethod + def _connect_progress_slots( + sender: ProgressSignalContainer, con_type: ConnectionType, begin_slot, progress_slot, end_slot): + sender.begin_update.connect(begin_slot, con_type) + sender.progress.connect(progress_slot, con_type) + sender.update_completed.connect(end_slot, con_type) + @staticmethod def _set_retain_size_policy(widget: QWidget, value: bool): policy = widget.sizePolicy() policy.setRetainSizeWhenHidden(value) widget.setSizePolicy(policy)