Skip to content

Commit

Permalink
Implement 'Export Scene to Image'
Browse files Browse the repository at this point in the history
  • Loading branch information
rbreu committed Nov 28, 2023
1 parent 050451a commit 752ca47
Show file tree
Hide file tree
Showing 10 changed files with 373 additions and 11 deletions.
15 changes: 11 additions & 4 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions beeref/actions/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,21 @@
'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',
Expand Down
1 change: 1 addition & 0 deletions beeref/actions/menu_structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
MENU_SEPARATOR,
'save',
'save_as',
'export_scene',
MENU_SEPARATOR,
'quit',
],
Expand Down
70 changes: 70 additions & 0 deletions beeref/fileio/export.py
Original file line number Diff line number Diff line change
@@ -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)
33 changes: 33 additions & 0 deletions beeref/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
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
Expand Down Expand Up @@ -104,8 +105,10 @@ def on_scene_changed(self, region):
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):
Expand Down Expand Up @@ -381,6 +384,36 @@ def on_action_save(self):
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()
Expand Down
66 changes: 66 additions & 0 deletions beeref/widgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,69 @@ def __init__(self, parent):
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())
11 changes: 5 additions & 6 deletions beeref/widgets/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,19 +105,18 @@ def __init__(self, parent):
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()
Expand Down
100 changes: 100 additions & 0 deletions tests/fileio/test_export.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 752ca47

Please sign in to comment.