diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index f2e6806..745aeec 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -8,6 +8,7 @@ Added
settings.
* The opacity of images can be changed (Images -> Change Opacity).
* Images can be set to display as grayscale (Images -> Grayscale).
+* The scene can now also be exported as SVG
Fixed
diff --git a/beeref/fileio/export.py b/beeref/fileio/export.py
index 223483d..c5795c8 100644
--- a/beeref/fileio/export.py
+++ b/beeref/fileio/export.py
@@ -13,21 +13,40 @@
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see
Problem writing file {filename}
{err_msg}
') def on_action_quit(self): logger.info('User quit. Exiting...') diff --git a/tests/fileio/test_export.py b/tests/fileio/test_export.py index 12b775e..5314cca 100644 --- a/tests/fileio/test_export.py +++ b/tests/fileio/test_export.py @@ -1,14 +1,45 @@ import os import stat -from unittest.mock import patch, ANY +from unittest.mock import patch, ANY, MagicMock import pytest from PyQt6 import QtGui, QtCore from beeref import constants -from beeref.items import BeePixmapItem +from beeref.items import BeePixmapItem, BeeTextItem from beeref.fileio.errors import BeeFileIOError -from beeref.fileio.export import SceneToPixmapExporter +from beeref.fileio.export import ( + exporter_registry, + SceneToPixmapExporter, + SceneToSVGExporter, +) + + +@pytest.mark.parametrize('key,expected', + [('png', SceneToPixmapExporter), + ('jpg', SceneToPixmapExporter), + ('svg', SceneToSVGExporter)]) +def test_registry(key, expected): + exporter_registry[key] == expected + + +@patch('beeref.widgets.SceneToPixmapExporterDialog.exec', return_value=True) +@patch('beeref.widgets.SceneToPixmapExporterDialog.value', + return_value=QtCore.QSize(100, 200)) +def test_scene_to_pixmap_exporter_get_user_input(value_mock, exec_mock, view): + exporter = SceneToPixmapExporter(view.scene) + value = exporter.get_user_input(None) + assert value is True + assert exporter.size == QtCore.QSize(100, 200) + + +@patch('beeref.widgets.SceneToPixmapExporterDialog.exec', return_value=False) +@patch('beeref.widgets.SceneToPixmapExporterDialog.value') +def test_scene_to_pixmap_exporter_get_user_input_when_canceled( + value_mock, exec_mock, view): + exporter = SceneToPixmapExporter(view.scene) + value = exporter.get_user_input(None) + assert value is False def test_scene_to_pixmap_exporter_default_size_and_margin(view): @@ -54,7 +85,9 @@ def test_scene_to_pixmap_exporter_render_sets_margins(render_mock, view): exporter = SceneToPixmapExporter(view.scene) assert exporter.margin == 36 assert exporter.default_size == QtCore.QSize(1072, 1272) - exporter.render_to_image(QtCore.QSize(536, 636)) + + exporter.size = QtCore.QSize(536, 636) + exporter.render_to_image() render_mock.assert_called_once_with( ANY, @@ -70,24 +103,61 @@ def test_scene_to_pixmap_exporter_render_renders_scene(view): 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)) + + exporter.size = QtCore.QSize(536, 636) + image = exporter.render_to_image() 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): +def test_scene_to_pixmap_exporter_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)) + exporter.size = QtCore.QSize(100, 120) + exporter.export(filename) with open(filename, 'rb') as f: assert f.read().startswith(b'\x89PNG') -def test_scene_to_pixmap_export_when_file_not_writeable(view, tmpdir): +def test_scene_to_pixmap_exporter_export_with_worker(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.size = QtCore.QSize(100, 120) + worker = MagicMock(canceled=False) + exporter.export(filename, worker) + + worker.begin_processing.emit.assert_called_once_with(1) + worker.progress.emit.assert_called_once_with(1) + worker.finished.emit.assert_called_once_with(filename, []) + with open(filename, 'rb') as f: + assert f.read().startswith(b'\x89PNG') + + +def test_scene_to_pixmap_exporter_export_with_worker_when_canceled( + 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.size = QtCore.QSize(100, 120) + worker = MagicMock(canceled=True) + exporter.export(filename, worker) + + worker.begin_processing.emit.assert_called_once_with(1) + worker.progress.emit.assert_not_called() + worker.finished.emit.assert_called_once_with(filename, []) + os.path.exists(filename) is False + + +def test_scene_to_pixmap_exporter_export_when_file_not_writeable(view, tmpdir): filename = os.path.join(tmpdir, 'foo.png') with open(filename, 'w') as f: f.write('foo') @@ -96,7 +166,301 @@ def test_scene_to_pixmap_export_when_file_not_writeable(view, tmpdir): item = BeePixmapItem(item_img) view.scene.addItem(item) exporter = SceneToPixmapExporter(view.scene) + exporter.size = QtCore.QSize(100, 120) with pytest.raises(BeeFileIOError) as e: - exporter.export(filename, QtCore.QSize(100, 120)) + exporter.export(filename) assert e.filename == filename + + +def test_scene_to_pixmap_exporter_export_when_file_not_writeable_with_worker( + 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) + exporter.size = QtCore.QSize(100, 120) + worker = MagicMock(canceled=False) + + exporter.export(filename, worker=worker) + worker.begin_processing.emit.assert_called_once_with(1) + worker.progress.emit.assert_not_called() + worker.finished.emit.assert_called_once_with( + filename, ['Error writing file']) + + +def test_scene_to_svg_exporter_get_user_input(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) + + assert view.scene.sceneRect().size().toSize() == QtCore.QSize(300, 100) + exporter = SceneToSVGExporter(view.scene) + value = exporter.get_user_input(None) + assert value is True + assert exporter.size == QtCore.QSize(318, 118) + + +def test_scene_to_svg_exporter_render_pixmap_items(view): + item1 = BeePixmapItem( + QtGui.QImage(100, 110, QtGui.QImage.Format.Format_RGB32)) + item1.setPos(QtCore.QPointF(20, 30)) + view.scene.addItem(item1) + + item2 = BeePixmapItem( + QtGui.QImage(70, 77, QtGui.QImage.Format.Format_RGB32)) + item2.setPos(QtCore.QPointF(50, 50)) + item2.setZValue(-1) + view.scene.addItem(item2) + + exporter = SceneToSVGExporter(view.scene) + exporter.size = QtCore.QSize(200, 400) + exporter.margin = 5 + svg = exporter.render_to_svg() + + assert svg.tag == 'svg' + assert svg.get('width') == '200' + assert svg.get('height') == '400' + assert svg.get('xmlns') == 'http://www.w3.org/2000/svg' + assert svg.get('xmlns:xlink') == 'http://www.w3.org/1999/xlink' + assert len(svg) == 2 + + element = svg[0] # item2 + assert element.tag == 'image' + assert element.get('xlink:href').startswith('data:image/png;base64,iVBOR') + assert element.get('width') == '70.0' + assert element.get('height') == '77.0' + assert element.get('image-rendering') == 'optimizeQuality' + assert element.get('transform') == 'rotate(0.0 35.0 25.0)' + assert element.get('x') == '35.0' + assert element.get('y') == '25.0' + assert element.get('opacity') == '1.0' + + element = svg[1] # item1 + assert element.tag == 'image' + assert element.get('width') == '100.0' + assert element.get('height') == '110.0' + assert element.get('image-rendering') == 'optimizeQuality' + assert element.get('transform') == 'rotate(0.0 5.0 5.0)' + assert element.get('x') == '5.0' + assert element.get('y') == '5.0' + assert element.get('opacity') == '1.0' + + +def test_scene_to_svg_exporter_render_pixmap_with_crop(view): + item = BeePixmapItem( + QtGui.QImage(100, 110, QtGui.QImage.Format.Format_RGB32)) + item.setPos(QtCore.QPointF(20, 30)) + item.crop = QtCore.QRectF(20, 25, 30, 33) + view.scene.addItem(item) + exporter = SceneToSVGExporter(view.scene) + exporter.size = QtCore.QSize(200, 400) + exporter.margin = 5 + svg = exporter.render_to_svg() + + assert len(svg) == 1 + + element = svg[0] + assert element.tag == 'image' + assert element.get('width') == '30.0' + assert element.get('height') == '33.0' + assert element.get('transform') == 'rotate(0.0 -15.0 -20.0)' + assert element.get('x') == '5.0' + assert element.get('y') == '5.0' + + +def test_scene_to_svg_exporter_render_pixmap_with_rotation(view): + item = BeePixmapItem( + QtGui.QImage(100, 110, QtGui.QImage.Format.Format_RGB32)) + item.setPos(QtCore.QPointF(20, 30)) + item.setRotation(90) + view.scene.addItem(item) + exporter = SceneToSVGExporter(view.scene) + exporter.size = QtCore.QSize(200, 400) + exporter.margin = 5 + svg = exporter.render_to_svg() + + assert len(svg) == 1 + + element = svg[0] + assert element.tag == 'image' + assert element.get('transform') == 'rotate(90.0 115.0 5.0)' + assert element.get('x') == '115.0' + assert element.get('y') == '5.0' + + +def test_scene_to_svg_exporter_render_pixmap_with_opacity(view): + item = BeePixmapItem( + QtGui.QImage(100, 110, QtGui.QImage.Format.Format_RGB32)) + item.setPos(QtCore.QPointF(20, 30)) + item.setOpacity(0.75) + view.scene.addItem(item) + exporter = SceneToSVGExporter(view.scene) + exporter.size = QtCore.QSize(200, 400) + exporter.margin = 5 + svg = exporter.render_to_svg() + + assert len(svg) == 1 + + element = svg[0] + assert element.tag == 'image' + assert element.get('opacity') == '0.75' + + +def test_scene_to_svg_exporter_render_pixmap_with_flip(view): + item = BeePixmapItem( + QtGui.QImage(100, 110, QtGui.QImage.Format.Format_RGB32)) + item.setPos(QtCore.QPointF(20, 30)) + item.do_flip() + view.scene.addItem(item) + exporter = SceneToSVGExporter(view.scene) + exporter.size = QtCore.QSize(200, 400) + exporter.margin = 5 + svg = exporter.render_to_svg() + + assert len(svg) == 1 + + element = svg[0] + assert element.tag == 'image' + assert element.get('transform') == ( + 'translate(105.0 5.0) scale(-1.0 1)' + ' translate(-105.0 -5.0) rotate(0.0 105.0 5.0)') + assert element.get('x') == '105.0' + assert element.get('y') == '5.0' + + +def test_scene_to_svg_exporter_render_text(view): + item = BeeTextItem('foo') + item.setPos(QtCore.QPointF(20, 30)) + view.scene.addItem(item) + exporter = SceneToSVGExporter(view.scene) + exporter.size = QtCore.QSize(200, 400) + exporter.margin = 5 + svg = exporter.render_to_svg() + + assert len(svg) == 1 + + element = svg[0] + assert element.tag == 'text' + assert element.text == 'foo' + assert element.get('dominant-baseline') == 'hanging' + assert 'font-family' in element.get('style') + assert element.get('transform') == 'rotate(0.0 5.0 5.0)' + assert element.get('x') == '5.0' + assert element.get('y') == '5.0' + + +def test_scene_to_svg_exporter_export_when_file_not_writeable(view, tmpdir): + filename = os.path.join(tmpdir, 'foo.svg') + with open(filename, 'w') as f: + f.write('foo') + os.chmod(filename, stat.S_IREAD) + item = BeeTextItem('foo') + view.scene.addItem(item) + exporter = SceneToSVGExporter(view.scene) + exporter.size = QtCore.QSize(100, 120) + + with pytest.raises(BeeFileIOError) as e: + exporter.export(filename) + assert e.filename == filename + + +def test_scene_to_svg_exporter_render_with_worker(view): + item = BeeTextItem('foo') + item.setPos(QtCore.QPointF(20, 30)) + view.scene.addItem(item) + exporter = SceneToSVGExporter(view.scene) + exporter.size = QtCore.QSize(200, 400) + exporter.margin = 5 + + worker = MagicMock(canceled=False) + svg = exporter.render_to_svg(worker=worker) + assert len(svg) == 1 + worker.progress.emit.assert_called_once_with(0) + + +def test_scene_to_svg_exporter_render_with_worker_canceled(view): + item = BeeTextItem('foo') + item.setPos(QtCore.QPointF(20, 30)) + view.scene.addItem(item) + exporter = SceneToSVGExporter(view.scene) + exporter.size = QtCore.QSize(200, 400) + exporter.margin = 5 + + worker = MagicMock(canceled=True) + svg = exporter.render_to_svg(worker=worker) + assert svg is None + + +def test_scene_to_svg_exporter_export_writes_svg(view, tmpdir): + filename = os.path.join(tmpdir, 'foo.svg') + item = BeeTextItem('foo') + view.scene.addItem(item) + exporter = SceneToSVGExporter(view.scene) + exporter.size = QtCore.QSize(100, 120) + exporter.export(filename) + + with open(filename, 'rb') as f: + assert f.read().startswith(b'