diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b67c08c..0aeb965 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,12 +8,19 @@ Added small images and images with an alpha channel will be stored as PNG, the rest as JPG. In the newly created settings dialog, this behaviour can be changed to always use PNG (the former behaviour) or - always JPG. To apply this behaviour to your old bee files, you can - save them as new files. -* Antialias/smoothing for displaying images. For images being + always JPG. To apply this behaviour to already saved images in + existing bee files, you can save them as new files. +* Antialias/smoothing for displaying images. (For images being displayed at a large zoom factor, smoothing will turn off to make - sure that icons, pixel sprites etc can be viewed correctly. + sure that icons, pixel sprites etc can be viewed correctly.) +* A scene can now be exported to a single image (File -> Export Scene...) + + +Changed +------- + * "Save as" will now open pre-select the folder of the currently opened file +* "Save" and "Save as" are now inactive when the scene is empty Fixed diff --git a/beeref/actions/actions.py b/beeref/actions/actions.py index 0b21ecc..a74ac53 100644 --- a/beeref/actions/actions.py +++ b/beeref/actions/actions.py @@ -25,12 +25,21 @@ actions = [ 'text': '&Save', 'shortcuts': ['Ctrl+S'], 'callback': 'on_action_save', + 'group': 'active_when_items_in_scene', }, { 'id': 'save_as', 'text': 'Save &As...', 'shortcuts': ['Ctrl+Shift+S'], 'callback': 'on_action_save_as', + 'group': 'active_when_items_in_scene', + }, + { + 'id': 'export_scene', + 'text': 'E&xport Scene...', + 'shortcuts': ['Ctrl+Shift+E'], + 'callback': 'on_action_export_scene', + 'group': 'active_when_items_in_scene', }, { 'id': 'quit', diff --git a/beeref/actions/menu_structure.py b/beeref/actions/menu_structure.py index ee198de..44f4a75 100644 --- a/beeref/actions/menu_structure.py +++ b/beeref/actions/menu_structure.py @@ -28,6 +28,7 @@ menu_structure = [ MENU_SEPARATOR, 'save', 'save_as', + 'export_scene', MENU_SEPARATOR, 'quit', ], diff --git a/beeref/fileio/export.py b/beeref/fileio/export.py new file mode 100644 index 0000000..5808bc7 --- /dev/null +++ b/beeref/fileio/export.py @@ -0,0 +1,70 @@ +# This file is part of BeeRef. +# +# BeeRef 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. +# +# BeeRef 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 BeeRef. If not, see . + +import logging + +from PyQt6 import QtCore, QtGui + +from .errors import BeeFileIOError +from beeref import constants + + +logger = logging.getLogger(__name__) + + +class SceneToPixmapExporter: + """For exporting the scene to a single image.""" + + MARGIN = 100 + + def __init__(self, scene): + self.scene = scene + self.scene.cancel_crop_mode() + self.scene.set_selected_all_items(False) + # Selection outlines/handles will be rendered to the exported + # image, and they also influence the size of the sceneRect. + # So deselect first. + size = self.scene.sceneRect().size() + if isinstance(size, QtCore.QSizeF): + size = size.toSize() + self.margin = max(size.width(), size.height()) * 0.03 + self.default_size = size.grownBy( + QtCore.QMargins(*([int(self.margin)] * 4))) + logger.debug(f'Default export size: {self.default_size}') + logger.debug(f'Default export margin: {self.margin}') + + def render_to_image(self, size): + logger.debug(f'Final export size: {size}') + margin = self.margin * size.width() / self.default_size.width() + logger.debug(f'Final export margin: {margin}') + + image = QtGui.QImage(size, QtGui.QImage.Format.Format_RGB32) + image.fill(QtGui.QColor(*constants.COLORS['Scene:Canvas'])) + painter = QtGui.QPainter(image) + painter.setViewport(QtCore.QRect( + int(margin), + int(margin), + int(size.width() - 2 * margin), + int(size.height() - 2 * margin))) + self.scene.render(painter) + painter.end() + return image + + def export(self, filename, size): + logger.debug(f'Exporting scene to {filename}') + image = self.render_to_image(size) + if not image.save(filename): + raise BeeFileIOError( + msg=str('Error writing image'), filename=filename) diff --git a/beeref/view.py b/beeref/view.py index bed8e0a..391b3d9 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -26,6 +26,7 @@ from beeref import commands from beeref.config import CommandlineArgs, BeeSettings from beeref import constants from beeref import fileio +from beeref.fileio.export import SceneToPixmapExporter from beeref import widgets from beeref.items import BeePixmapItem, BeeTextItem from beeref.main_controls import MainControlsMixin @@ -104,8 +105,10 @@ class BeeGraphicsView(MainControlsMixin, logger.debug('No items in scene') self.setTransform(QtGui.QTransform()) self.welcome_overlay.show() + self.actiongroup_set_enabled('active_when_items_in_scene', False) else: self.welcome_overlay.hide() + self.actiongroup_set_enabled('active_when_items_in_scene', True) self.recalc_scene_rect() def on_can_redo_changed(self, can_redo): @@ -381,6 +384,36 @@ class BeeGraphicsView(MainControlsMixin, else: self.do_save(self.filename, create_new=False) + def on_action_export_scene(self): + directory = os.path.dirname(self.filename) if self.filename else None + filename, f = QtWidgets.QFileDialog.getSaveFileName( + parent=self, + caption='Export Scene to Image', + directory=directory, + filter=';;'.join(('Image Files (*.png *.jpg *.jpeg)', + 'PNG (*.png)', + 'JPEG (*.jpg *.jpeg)'))) + print(';;'.join(('Image Files (*.png *.jpg *.jpeg)', + 'PNG (*.png)', + 'JPEG (*.jpg *.jpeg)'))) + if filename: + logger.debug(f'Got export filename {filename}') + exporter = SceneToPixmapExporter(self.scene) + dialog = widgets.SceneToPixmapExporterDialog( + parent=self, + default_size=exporter.default_size, + ) + if dialog.exec(): + size = dialog.value() + logger.debug(f'Got export size {size}') + try: + exporter.export(filename, size) + except fileio.BeeFileIOError as e: + QtWidgets.QMessageBox.warning( + self, + 'Problem exporting scene', + str(e)) + def on_action_quit(self): logger.info('User quit. Exiting...') self.app.quit() diff --git a/beeref/widgets/__init__.py b/beeref/widgets/__init__.py index ff1402d..4ad9cf2 100644 --- a/beeref/widgets/__init__.py +++ b/beeref/widgets/__init__.py @@ -226,3 +226,69 @@ class DebugLogDialog(QtWidgets.QDialog): def copy_to_clipboard(self): clipboard = QtWidgets.QApplication.clipboard() clipboard.setText(self.log_txt) + + +class SceneToPixmapExporterDialog(QtWidgets.QDialog): + MIN_SIZE = 10 + MAX_SIZE = 100000 + + def __init__(self, parent, default_size): + super().__init__(parent) + self.default_size = default_size + if (self.default_size.width() > self.MAX_SIZE + or self.default_size.width() >= self.MAX_SIZE): + self.default_size.scale( + self.MAX_SIZE, self.MAX_SIZE, + Qt.AspectRatioMode.KeepAspectRatio) + + self.ignore_change = False + self.setWindowTitle('Export Scene to Image') + self.setWindowModality(Qt.WindowModality.WindowModal) + layout = QtWidgets.QGridLayout() + self.setLayout(layout) + + width_label = QtWidgets.QLabel('Width:') + layout.addWidget(width_label, 0, 0) + self.width_input = QtWidgets.QSpinBox() + self.width_input.setRange(self.MIN_SIZE, self.MAX_SIZE) + self.width_input.setValue(default_size.width()) + self.width_input.valueChanged.connect(self.on_width_changed) + layout.addWidget(self.width_input, 0, 1) + + height_label = QtWidgets.QLabel('Height:') + layout.addWidget(height_label, 1, 0) + self.height_input = QtWidgets.QSpinBox() + self.height_input.setMinimum(10) + self.height_input.setRange(self.MIN_SIZE, self.MAX_SIZE) + self.height_input.setValue(default_size.height()) + self.height_input.valueChanged.connect(self.on_height_changed) + layout.addWidget(self.height_input, 1, 1) + + # Bottom row of buttons + buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.StandardButton.Ok | + QtWidgets.QDialogButtonBox.StandardButton.Cancel) + + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons, 3, 1) + + def on_width_changed(self, width): + if not self.ignore_change: + self.ignore_change = True + new = self.default_size.scaled( + width, self.MAX_SIZE, Qt.AspectRatioMode.KeepAspectRatio) + self.height_input.setValue(new.height()) + self.ignore_change = False + + def on_height_changed(self, height): + if not self.ignore_change: + self.ignore_change = True + new = self.default_size.scaled( + self.MAX_SIZE, height, Qt.AspectRatioMode.KeepAspectRatio) + self.width_input.setValue(new.width()) + self.ignore_change = False + + def value(self): + return QtCore.QSize(self.width_input.value(), + self.height_input.value()) diff --git a/beeref/widgets/settings.py b/beeref/widgets/settings.py index 0d096c9..68fb6ab 100644 --- a/beeref/widgets/settings.py +++ b/beeref/widgets/settings.py @@ -105,19 +105,18 @@ class SettingsDialog(QtWidgets.QDialog): layout.addWidget(tabs) # Bottom row of buttons - buttons = QtWidgets.QWidget() - btn_layout = QtWidgets.QHBoxLayout() - buttons.setLayout(btn_layout) + buttons = QtWidgets.QDialogButtonBox() reset_btn = QtWidgets.QPushButton('&Restore Defaults') reset_btn.setAutoDefault(False) reset_btn.clicked.connect(self.on_restore_defaults) - btn_layout.addWidget(reset_btn) + buttons.addButton(reset_btn, + QtWidgets.QDialogButtonBox.ButtonRole.ActionRole) close_btn = QtWidgets.QPushButton('&Close') close_btn.setAutoDefault(True) close_btn.clicked.connect(self.on_close) - btn_layout.addWidget(close_btn) - btn_layout.insertStretch(1) + buttons.addButton(close_btn, + QtWidgets.QDialogButtonBox.ButtonRole.ActionRole) layout.addWidget(buttons) self.show() diff --git a/tests/fileio/test_export.py b/tests/fileio/test_export.py new file mode 100644 index 0000000..41e33b6 --- /dev/null +++ b/tests/fileio/test_export.py @@ -0,0 +1,100 @@ +import os +import stat +from unittest.mock import patch +import pytest + +from PyQt6 import QtGui, QtCore + +from beeref import constants +from beeref.items import BeePixmapItem +from beeref.fileio.errors import BeeFileIOError +from beeref.fileio.export import SceneToPixmapExporter + + +def test_scene_to_pixmap_exporter_default_size_and_margin(view): + item1 = BeePixmapItem( + QtGui.QImage(100, 100, QtGui.QImage.Format.Format_RGB32)) + item1.setPos(QtCore.QPointF(0, 0)) + view.scene.addItem(item1) + + item2 = BeePixmapItem( + QtGui.QImage(100, 100, QtGui.QImage.Format.Format_RGB32)) + item1.setPos(QtCore.QPointF(200, 0)) + view.scene.addItem(item2) + + exporter = SceneToPixmapExporter(view.scene) + assert view.scene.sceneRect().size().toSize() == QtCore.QSize(300, 100) + assert (exporter.margin - 9) < 0.000001 + assert exporter.default_size == QtCore.QSize(318, 118) + + +def test_scene_to_pixmap_exporter_default_size_and_margin_when_selection(view): + item1 = BeePixmapItem( + QtGui.QImage(100, 100, QtGui.QImage.Format.Format_RGB32)) + item1.setPos(QtCore.QPointF(0, 0)) + view.scene.addItem(item1) + + item2 = BeePixmapItem( + QtGui.QImage(100, 100, QtGui.QImage.Format.Format_RGB32)) + item1.setPos(QtCore.QPointF(200, 0)) + view.scene.addItem(item2) + item2.setSelected(True) + + exporter = SceneToPixmapExporter(view.scene) + assert view.scene.sceneRect().size().toSize() == QtCore.QSize(300, 100) + assert (exporter.margin - 9) < 0.000001 + assert exporter.default_size == QtCore.QSize(318, 118) + + +@patch('PyQt6.QtGui.QPainter.setViewport') +def test_scene_to_pixmap_exporter_render_sets_margins(set_mock, view): + item = BeePixmapItem( + QtGui.QImage(1000, 1200, QtGui.QImage.Format.Format_RGB32)) + view.scene.addItem(item) + exporter = SceneToPixmapExporter(view.scene) + assert exporter.margin == 36 + assert exporter.default_size == QtCore.QSize(1072, 1272) + exporter.render_to_image(QtCore.QSize(536, 636)) + + set_mock.assert_called_once_with( + QtCore.QRect(18, 18, 500, 600)) + + +def test_scene_to_pixmap_exporter_render_renders_scene(view): + item_img = QtGui.QImage(1000, 1200, QtGui.QImage.Format.Format_RGB32) + item_img.fill(QtGui.QColor(11, 22, 33)) + item = BeePixmapItem(item_img) + view.scene.addItem(item) + exporter = SceneToPixmapExporter(view.scene) + assert exporter.margin == 36 + assert exporter.default_size == QtCore.QSize(1072, 1272) + image = exporter.render_to_image(QtCore.QSize(536, 636)) + assert image.pixel(1, 1) == QtGui.QColor(*constants.COLORS['Scene:Canvas']) + assert image.pixel(100, 100) == QtGui.QColor(11, 22, 33) + + +def test_scene_to_pixmap_export_writes_image(view, tmpdir): + filename = os.path.join(tmpdir, 'foo.png') + item_img = QtGui.QImage(1000, 1200, QtGui.QImage.Format.Format_RGB32) + item = BeePixmapItem(item_img) + view.scene.addItem(item) + exporter = SceneToPixmapExporter(view.scene) + exporter.export(filename, QtCore.QSize(100, 120)) + + with open(filename, 'rb') as f: + assert f.read().startswith(b'\x89PNG') + + +def test_scene_to_pixmap_export_when_file_not_writeable(view, tmpdir): + filename = os.path.join(tmpdir, 'foo.png') + with open(filename, 'w') as f: + f.write('foo') + os.chmod(filename, stat.S_IREAD) + item_img = QtGui.QImage(1000, 1200, QtGui.QImage.Format.Format_RGB32) + item = BeePixmapItem(item_img) + view.scene.addItem(item) + exporter = SceneToPixmapExporter(view.scene) + + with pytest.raises(BeeFileIOError) as e: + exporter.export(filename, QtCore.QSize(100, 120)) + assert e.filename == filename diff --git a/tests/test_view.py b/tests/test_view.py index d6397a7..3e23c14 100644 --- a/tests/test_view.py +++ b/tests/test_view.py @@ -292,6 +292,52 @@ def test_on_action_save_when_no_filename(save_as_mock, view, imgfilename3x3): view.scene.cancel_crop_mode.assert_called_once_with() +@patch('beeref.widgets.SceneToPixmapExporterDialog.exec') +@patch('beeref.widgets.SceneToPixmapExporterDialog.value') +@patch('PyQt6.QtWidgets.QFileDialog.getSaveFileName') +def test_on_action_export_scene( + file_mock, value_mock, exec_mock, view, tmpdir): + item = BeeTextItem('foo') + view.scene.addItem(item) + filename = os.path.join(tmpdir, 'test.png') + assert os.path.exists(filename) is False + file_mock.return_value = (filename, None) + exec_mock.return_value = 1 + value_mock.return_value = QtCore.QSize(100, 100) + view.on_action_export_scene() + img = QtGui.QImage(filename) + assert img.size() == QtCore.QSize(100, 100) + + +@patch('beeref.widgets.SceneToPixmapExporterDialog.exec') +@patch('beeref.widgets.SceneToPixmapExporterDialog.value') +@patch('PyQt6.QtWidgets.QFileDialog.getSaveFileName') +def test_on_action_export_scene_no_filename( + file_mock, value_mock, exec_mock, view): + item = BeeTextItem('foo') + view.scene.addItem(item) + file_mock.return_value = (None, None) + view.on_action_export_scene() + exec_mock.assert_not_called() + value_mock.assert_not_called() + + +@patch('beeref.widgets.SceneToPixmapExporterDialog.exec') +@patch('beeref.widgets.SceneToPixmapExporterDialog.value') +@patch('PyQt6.QtWidgets.QFileDialog.getSaveFileName') +def test_on_action_export_scene_settings_input_canceled( + file_mock, value_mock, exec_mock, view, tmpdir): + item = BeeTextItem('foo') + view.scene.addItem(item) + filename = os.path.join(tmpdir, 'test.png') + assert os.path.exists(filename) is False + file_mock.return_value = (filename, None) + exec_mock.return_value = 0 + view.on_action_export_scene() + value_mock.assert_not_called() + assert os.path.exists(filename) is False + + @patch('beeref.widgets.settings.SettingsDialog.show') def test_on_action_settings(show_mock, view): view.on_action_settings() diff --git a/tests/test_widgets.py b/tests/test_widgets.py index bcd4b3e..33f1dae 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -4,7 +4,10 @@ from PyQt6 import QtCore, QtWidgets from PyQt6.QtCore import Qt from beeref.config import logfile_name -from beeref.widgets import DebugLogDialog, RecentFilesModel +from beeref.widgets import ( + DebugLogDialog, + RecentFilesModel, + SceneToPixmapExporterDialog) def test_debug_log_dialog(qtbot, settings, view): @@ -38,3 +41,31 @@ def test_recent_files_model_data_fontrole(view): index.row.return_value = 1 font = model.data(index, QtCore.Qt.ItemDataRole.FontRole) assert font.underline() is True + + +def test_scene_to_pixmap_exporter_dialog_sets_defaults(view): + dlg = SceneToPixmapExporterDialog(view, QtCore.QSize(1200, 1600)) + assert dlg.width_input.value() == 1200 + assert dlg.height_input.value() == 1600 + assert dlg.value() == QtCore.QSize(1200, 1600) + + +def test_scene_to_pixmap_exporter_dialog_sets_defaults_when_too_large(view): + dlg = SceneToPixmapExporterDialog(view, QtCore.QSize(120000, 160000)) + assert dlg.width_input.value() == 75000 + assert dlg.height_input.value() == 100000 + assert dlg.value() == QtCore.QSize(75000, 100000) + + +def test_scene_to_pixmap_exporter_dialog_updates_height(view): + dlg = SceneToPixmapExporterDialog(view, QtCore.QSize(1200, 1600)) + dlg.width_input.setValue(600) + assert dlg.height_input.value() == 800 + assert dlg.value() == QtCore.QSize(600, 800) + + +def test_scene_to_pixmap_exporter_dialog_updates_width(view): + dlg = SceneToPixmapExporterDialog(view, QtCore.QSize(1200, 1600)) + dlg.height_input.setValue(160) + assert dlg.width_input.value() == 120 + assert dlg.value() == QtCore.QSize(120, 160)