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)