mirror of
https://github.com/rbreu/beeref.git
synced 2026-03-11 08:54:28 +00:00
Implement 'Export Scene to Image'
This commit is contained in:
parent
050451a378
commit
752ca47da7
10 changed files with 373 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ menu_structure = [
|
|||
MENU_SEPARATOR,
|
||||
'save',
|
||||
'save_as',
|
||||
'export_scene',
|
||||
MENU_SEPARATOR,
|
||||
'quit',
|
||||
],
|
||||
|
|
|
|||
70
beeref/fileio/export.py
Normal file
70
beeref/fileio/export.py
Normal 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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
100
tests/fileio/test_export.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue