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,30 @@ # 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 + +from mtg_proxy_printer.units_and_sizes import RESOLUTION + +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 -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 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 @@ -37,10 +47,12 @@ "export_pdf", "create_printer", "Renderer", ] +PNGEncoderThreadLimit = BoundedSemaphore(max(1, process_cpu_count()-1)) + 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 logger.error("Tried to export a document with zero pages as a PDF. Aborting.") @@ -50,10 +62,38 @@ for document_index in range(total_documents): logger.info(f"Creating PDF ({document_index+1}/{total_documents}) with up to {pages_to_print} pages.") printer = PDFPrinter(document, file_path, parent, document_index, pages_to_print) printer.print_document() + +def export_png(document: Document, file_path: str, parent: QObject = None): + file_path = Path(file_path) + page_count = document.rowCount() + if not page_count: # No pages in document + logger.error("Tried to export a document with zero pages. Aborting.") + 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, parent) + number_width = len(str(page_count)) + parent = file_path.parent + 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(_compress_single_image, image, output_path)) + + +@with_database_write_lock(PNGEncoderThreadLimit) +def _compress_single_image(image: QImage, output_path: Path): + image.save(str(output_path), "PNG", 0) + def create_printer(renderer: "Renderer") -> QPrinter: printer = QPrinter(QPrinter.PrinterMode.HighResolution) layout = renderer.document.page_layout page_layout = layout.to_page_layout(renderer.render_mode) 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 @@ -49,10 +49,11 @@ logger = get_logger(__name__) del get_logger __all__ = [ "SavePDFDialog", + "SavePNGDialog", "SaveDocumentAsDialog", "LoadDocumentDialog", "AboutDialog", "PrintPreviewDialog", "PrintDialog", @@ -110,10 +111,52 @@ @Slot() def on_reject(self): logger.debug("User aborted saving to PDF. Doing nothing.") + +class SavePNGDialog(QFileDialog): + + def __init__(self, parent: QWidget, 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] + mtg_proxy_printer.print.export_png(self.document, path, self) + 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)