Implement 'Export Scene to Image'

This commit is contained in:
Rebecca Breu 2023-11-28 17:57:19 +01:00
parent 050451a378
commit 752ca47da7
10 changed files with 373 additions and 11 deletions

View file

@ -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

View file

@ -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',

View file

@ -28,6 +28,7 @@ menu_structure = [
MENU_SEPARATOR,
'save',
'save_as',
'export_scene',
MENU_SEPARATOR,
'quit',
],

70
beeref/fileio/export.py Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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)

View file

@ -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()

View file

@ -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())

View file

@ -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()

100
tests/fileio/test_export.py Normal file
View file

@ -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

View file

@ -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()

View file

@ -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)