mirror of
https://github.com/rbreu/beeref.git
synced 2026-03-11 08:54:28 +00:00
Add export format SVG
This commit is contained in:
parent
b0775cace4
commit
1eab4712d3
7 changed files with 686 additions and 51 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -13,21 +13,40 @@
|
|||
# You should have received a copy of the GNU General Public License
|
||||
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from PyQt6 import QtCore, QtGui
|
||||
|
||||
from .errors import BeeFileIOError
|
||||
from beeref import constants
|
||||
from beeref import constants, widgets
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SceneToPixmapExporter:
|
||||
"""For exporting the scene to a single image."""
|
||||
class ExporterRegistry(dict):
|
||||
|
||||
MARGIN = 100
|
||||
DEFAULT_TYPE = 'default exporter'
|
||||
|
||||
def __getitem__(self, key):
|
||||
key = key.removeprefix('.')
|
||||
exp = self.get(key, super().__getitem__(self.DEFAULT_TYPE))
|
||||
logger.debug(f'Exporter for type {key}: {exp}')
|
||||
return exp
|
||||
|
||||
|
||||
exporter_registry = ExporterRegistry()
|
||||
|
||||
|
||||
def register_exporter(cls):
|
||||
exporter_registry[cls.TYPE] = cls
|
||||
return cls
|
||||
|
||||
|
||||
class ExporterBase:
|
||||
"""For exporting the scene to a single image."""
|
||||
|
||||
def __init__(self, scene):
|
||||
self.scene = scene
|
||||
|
|
@ -46,19 +65,40 @@ class SceneToPixmapExporter:
|
|||
logger.debug(f'Default export margin: {self.margin}')
|
||||
logger.debug(f'Default export size with margins: {self.default_size}')
|
||||
|
||||
def render_to_image(self, size):
|
||||
logger.debug(f'Final export size: {size}')
|
||||
margin = self.margin * size.width() / self.default_size.width()
|
||||
|
||||
@register_exporter
|
||||
class SceneToPixmapExporter(ExporterBase):
|
||||
|
||||
TYPE = ExporterRegistry.DEFAULT_TYPE
|
||||
|
||||
def get_user_input(self, parent):
|
||||
"""Ask user for final export size."""
|
||||
|
||||
dialog = widgets.SceneToPixmapExporterDialog(
|
||||
parent=parent,
|
||||
default_size=self.default_size,
|
||||
)
|
||||
if dialog.exec():
|
||||
size = dialog.value()
|
||||
logger.debug(f'Got export size {size}')
|
||||
self.size = size
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def render_to_image(self):
|
||||
logger.debug(f'Final export size: {self.size}')
|
||||
margin = self.margin * self.size.width() / self.default_size.width()
|
||||
logger.debug(f'Final export margin: {margin}')
|
||||
|
||||
image = QtGui.QImage(size, QtGui.QImage.Format.Format_RGB32)
|
||||
image = QtGui.QImage(self.size, QtGui.QImage.Format.Format_RGB32)
|
||||
image.fill(QtGui.QColor(*constants.COLORS['Scene:Canvas']))
|
||||
painter = QtGui.QPainter(image)
|
||||
target_rect = QtCore.QRectF(
|
||||
margin,
|
||||
margin,
|
||||
size.width() - 2 * margin,
|
||||
size.height() - 2 * margin)
|
||||
self.size.width() - 2 * margin,
|
||||
self.size.height() - 2 * margin)
|
||||
logger.trace(f'Final export target_rect: {target_rect}')
|
||||
self.scene.render(painter,
|
||||
source=self.scene.itemsBoundingRect(),
|
||||
|
|
@ -66,9 +106,155 @@ class SceneToPixmapExporter:
|
|||
painter.end()
|
||||
return image
|
||||
|
||||
def export(self, filename, size):
|
||||
def export(self, filename, worker=None):
|
||||
logger.debug(f'Exporting scene to {filename}')
|
||||
image = self.render_to_image(size)
|
||||
if worker:
|
||||
worker.begin_processing.emit(1)
|
||||
|
||||
image = self.render_to_image()
|
||||
|
||||
if worker and worker.canceled:
|
||||
logger.debug('Export canceled')
|
||||
worker.finished.emit(filename, [])
|
||||
return
|
||||
|
||||
if not image.save(filename, quality=90):
|
||||
raise BeeFileIOError(
|
||||
msg=str('Error writing image'), filename=filename)
|
||||
msg = 'Error writing file'
|
||||
logger.debug(f'Export failed: {msg}')
|
||||
if worker:
|
||||
worker.finished.emit(filename, [msg])
|
||||
return
|
||||
else:
|
||||
raise BeeFileIOError(msg, filename=filename)
|
||||
|
||||
logger.debug('Export finished')
|
||||
if worker:
|
||||
worker.progress.emit(1)
|
||||
worker.finished.emit(filename, [])
|
||||
|
||||
|
||||
@register_exporter
|
||||
class SceneToSVGExporter(ExporterBase):
|
||||
|
||||
TYPE = 'svg'
|
||||
|
||||
def get_user_input(self, parent):
|
||||
self.size = self.default_size
|
||||
return True
|
||||
|
||||
def _get_textstyles(self, item):
|
||||
fontstylemap = {
|
||||
QtGui.QFont.Style.StyleNormal: 'normal',
|
||||
QtGui.QFont.Style.StyleItalic: 'italic',
|
||||
QtGui.QFont.Style.StyleOblique: 'oblique',
|
||||
}
|
||||
|
||||
font = item.font()
|
||||
fontsize = font.pointSize() * item.scale()
|
||||
families = ', '.join(font.families())
|
||||
fontstyle = fontstylemap[font.style()]
|
||||
|
||||
return ('white-space:pre',
|
||||
f'font-size:{fontsize}pt',
|
||||
f'font-family:{families}',
|
||||
f'font-weight:{font.weight()}',
|
||||
f'font-stretch:{font.stretch()}',
|
||||
f'font-style:{fontstyle}')
|
||||
|
||||
def render_to_svg(self, worker=None):
|
||||
svg = ET.Element(
|
||||
'svg',
|
||||
attrib={'width': str(self.size.width()),
|
||||
'height': str(self.size.height()),
|
||||
'xmlns': 'http://www.w3.org/2000/svg',
|
||||
'xmlns:xlink': 'http://www.w3.org/1999/xlink',
|
||||
})
|
||||
|
||||
rect = self.scene.itemsBoundingRect()
|
||||
offset = rect.topLeft() - QtCore.QPointF(self.margin, self.margin)
|
||||
|
||||
for i, item in enumerate(sorted(self.scene.items(),
|
||||
key=lambda x: x.zValue())):
|
||||
# z order in SVG specified via the order of elements in the tree
|
||||
pos = item.pos() - offset
|
||||
anchor = pos
|
||||
|
||||
if item.TYPE == 'text':
|
||||
styles = self._get_textstyles(item)
|
||||
element = ET.Element(
|
||||
'text',
|
||||
attrib={'style': ';'.join(styles),
|
||||
'dominant-baseline': 'hanging'})
|
||||
element.text = item.toPlainText()
|
||||
if item.TYPE == 'pixmap':
|
||||
width = item.width * item.scale()
|
||||
height = item.height * item.scale()
|
||||
pixmap, imgformat = item.pixmap_to_bytes(
|
||||
apply_grayscale=True,
|
||||
apply_crop=True)
|
||||
pixmap = base64.b64encode(pixmap).decode('ascii')
|
||||
element = ET.Element(
|
||||
'image',
|
||||
attrib={
|
||||
'xlink:href':
|
||||
f'data:image/{imgformat};base64,{pixmap}',
|
||||
'width': str(width),
|
||||
'height': str(height),
|
||||
'image-rendering': ('crisp-edges' if item.scale() > 2
|
||||
else 'optimizeQuality')})
|
||||
pos = pos + item.crop.topLeft()
|
||||
|
||||
transforms = []
|
||||
if item.flip() == -1:
|
||||
# The following is not recognised by Inkscape and not an
|
||||
# official standard:
|
||||
# element.set('transform-origin', f'{anchor.x()} {anchor.y()}')
|
||||
# Thus we need to fix the origin manually
|
||||
transforms.append(f'translate({anchor.x()} {anchor.y()})')
|
||||
transforms.append(f'scale({item.flip()} 1)')
|
||||
transforms.append(f'translate(-{anchor.x()} -{anchor.y()})')
|
||||
transforms.append(
|
||||
f'rotate({item.rotation()} {anchor.x()} {anchor.y()})')
|
||||
|
||||
element.set('transform', ' '.join(transforms))
|
||||
element.set('x', str(pos.x()))
|
||||
element.set('y', str(pos.y()))
|
||||
element.set('opacity', str(item.opacity()))
|
||||
|
||||
svg.append(element)
|
||||
if worker:
|
||||
worker.progress.emit(i)
|
||||
if worker.canceled:
|
||||
return
|
||||
|
||||
return svg
|
||||
|
||||
def export(self, filename, worker=None):
|
||||
logger.debug(f'Exporting scene to {filename}')
|
||||
if worker:
|
||||
worker.begin_processing.emit(len(self.scene.items()))
|
||||
|
||||
svg = self.render_to_svg(worker)
|
||||
|
||||
if worker and worker.canceled:
|
||||
logger.debug('Export canceled')
|
||||
worker.finished.emit(filename, [])
|
||||
return
|
||||
|
||||
tree = ET.ElementTree(svg)
|
||||
ET.indent(tree, space=' ')
|
||||
|
||||
try:
|
||||
with open(filename, 'w') as f:
|
||||
tree.write(f, encoding='unicode', xml_declaration=True)
|
||||
except OSError as e:
|
||||
logger.debug(f'Export failed: {e}')
|
||||
if worker:
|
||||
worker.finished.emit(filename, [str(e)])
|
||||
return
|
||||
else:
|
||||
raise BeeFileIOError(msg=str(e), filename=filename) from e
|
||||
|
||||
logger.debug('Export finished')
|
||||
if worker:
|
||||
worker.finished.emit(filename, [])
|
||||
|
|
|
|||
|
|
@ -169,12 +169,20 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem):
|
|||
logger.debug(f'Found format {formt} for {self}')
|
||||
return formt
|
||||
|
||||
def pixmap_to_bytes(self):
|
||||
def pixmap_to_bytes(self, apply_grayscale=False, apply_crop=False):
|
||||
"""Convert the pixmap data to PNG bytestring."""
|
||||
barray = QtCore.QByteArray()
|
||||
buffer = QtCore.QBuffer(barray)
|
||||
buffer.open(QtCore.QIODevice.OpenModeFlag.WriteOnly)
|
||||
img = self.pixmap().toImage()
|
||||
if apply_grayscale and self.grayscale:
|
||||
pm = self._grayscale_pixmap
|
||||
else:
|
||||
pm = self.pixmap()
|
||||
|
||||
if apply_crop:
|
||||
pm = pm.copy(self.crop.toRect())
|
||||
|
||||
img = pm.toImage()
|
||||
imgformat = self.get_imgformat(img)
|
||||
img.save(buffer, imgformat.upper(), quality=90)
|
||||
return (barray.data(), imgformat)
|
||||
|
|
|
|||
|
|
@ -26,7 +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.fileio.export import exporter_registry
|
||||
from beeref import widgets
|
||||
from beeref.items import BeePixmapItem, BeeTextItem
|
||||
from beeref.main_controls import MainControlsMixin
|
||||
|
|
@ -416,31 +416,40 @@ class BeeGraphicsView(MainControlsMixin,
|
|||
parent=self,
|
||||
caption='Export Scene to Image',
|
||||
directory=directory,
|
||||
filter=';;'.join(('Image Files (*.png *.jpg *.jpeg)',
|
||||
filter=';;'.join(('Image Files (*.png *.jpg *.jpeg *.svg)',
|
||||
'PNG (*.png)',
|
||||
'JPEG (*.jpg *.jpeg)')))
|
||||
'JPEG (*.jpg *.jpeg)',
|
||||
'SVG (*.svg)')))
|
||||
|
||||
if filename:
|
||||
name, ext = os.path.splitext(filename)
|
||||
if not ext:
|
||||
ext = get_file_extension_from_format(formatstr)
|
||||
filename = f'{filename}.{ext}'
|
||||
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))
|
||||
if not filename:
|
||||
return
|
||||
|
||||
name, ext = os.path.splitext(filename)
|
||||
if not ext:
|
||||
ext = get_file_extension_from_format(formatstr)
|
||||
filename = f'{filename}.{ext}'
|
||||
logger.debug(f'Got export filename {filename}')
|
||||
|
||||
exporter_cls = exporter_registry[ext]
|
||||
exporter = exporter_cls(self.scene)
|
||||
if not exporter.get_user_input(self):
|
||||
return
|
||||
|
||||
self.worker = fileio.ThreadedIO(exporter.export, filename)
|
||||
self.worker.finished.connect(self.on_export_finished)
|
||||
self.progress = widgets.BeeProgressDialog(
|
||||
'Exporting %s' % filename,
|
||||
worker=self.worker,
|
||||
parent=self)
|
||||
self.worker.start()
|
||||
|
||||
def on_export_finished(self, filename, errors):
|
||||
if errors:
|
||||
err_msg = '</br>'.join(str(errors))
|
||||
QtWidgets.QMessageBox.warning(
|
||||
self,
|
||||
'Problem writing file',
|
||||
f'<p>Problem writing file {filename}</p><p>{err_msg}</p>')
|
||||
|
||||
def on_action_quit(self):
|
||||
logger.info('User quit. Exiting...')
|
||||
|
|
|
|||
|
|
@ -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'<?xml')
|
||||
|
||||
|
||||
def test_scene_to_svg_exporter_export_with_worker(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)
|
||||
worker = MagicMock(canceled=False)
|
||||
exporter.export(filename, worker)
|
||||
|
||||
worker.begin_processing.emit.assert_called_once_with(1)
|
||||
worker.progress.emit.assert_called_once_with(0)
|
||||
worker.finished.emit.assert_called_once_with(filename, [])
|
||||
with open(filename, 'rb') as f:
|
||||
assert f.read().startswith(b'<?xml')
|
||||
|
||||
|
||||
def test_scene_to_svg_exporter_export_with_worker_canceled(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)
|
||||
worker = MagicMock(canceled=True)
|
||||
exporter.export(filename, worker)
|
||||
|
||||
worker.begin_processing.emit.assert_called_once_with(1)
|
||||
worker.progress.emit.assert_called_once_with(0)
|
||||
worker.finished.emit.assert_called_once_with(filename, [])
|
||||
os.path.exists(filename) is False
|
||||
|
||||
|
||||
def test_scene_to_svg_exporter_export_when_file_not_writeable_with_worker(
|
||||
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)
|
||||
worker = MagicMock(canceled=False)
|
||||
|
||||
exporter.export(filename, worker=worker)
|
||||
worker.begin_processing.emit.assert_called_once_with(1)
|
||||
worker.progress.emit.assert_called_once_with(0)
|
||||
worker.finished.emit.assert_called_once()
|
||||
args = worker.finished.emit.call_args.args
|
||||
assert args[0] == filename
|
||||
assert len(args[1]) == 1
|
||||
|
|
|
|||
|
|
@ -164,17 +164,73 @@ def test_get_imgformat_png_when_setting_png(
|
|||
|
||||
def test_pixmap_to_bytes_png(qapp, imgfilename3x3):
|
||||
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
|
||||
item.crop = QtCore.QRectF(0, 0, 2, 2)
|
||||
item.grayscale = True
|
||||
data, imgformat = item.pixmap_to_bytes()
|
||||
assert imgformat == 'png'
|
||||
assert data.startswith(b'\x89PNG')
|
||||
pixmap = QtGui.QPixmap()
|
||||
pixmap.loadFromData(data)
|
||||
img = pixmap.toImage()
|
||||
assert img.allGray() is False
|
||||
assert img.size() == QtCore.QSize(3, 3)
|
||||
|
||||
|
||||
def test_pixmap_to_bytes_jpg(qapp, imgfilename3x3, settings):
|
||||
settings.setValue('Items/image_storage_format', 'jpg')
|
||||
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
|
||||
item.crop = QtCore.QRectF(0, 0, 2, 2)
|
||||
item.grayscale = True
|
||||
data, imgformat = item.pixmap_to_bytes()
|
||||
assert imgformat == 'jpg'
|
||||
assert data.startswith(b'\xff\xd8\xff\xe0\x00\x10JFIF')
|
||||
pixmap = QtGui.QPixmap()
|
||||
pixmap.loadFromData(data)
|
||||
img = pixmap.toImage()
|
||||
assert img.allGray() is False
|
||||
assert img.size() == QtCore.QSize(3, 3)
|
||||
|
||||
|
||||
def test_pixmap_to_bytes_apply_grayscale(qapp, imgfilename3x3):
|
||||
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
|
||||
item.crop = QtCore.QRectF(0, 0, 2, 2)
|
||||
item.grayscale = True
|
||||
data, imgformat = item.pixmap_to_bytes(apply_grayscale=True)
|
||||
assert imgformat == 'png'
|
||||
assert data.startswith(b'\x89PNG')
|
||||
pixmap = QtGui.QPixmap()
|
||||
pixmap.loadFromData(data)
|
||||
img = pixmap.toImage()
|
||||
assert img.allGray() is True
|
||||
assert img.size() == QtCore.QSize(3, 3)
|
||||
|
||||
|
||||
def test_pixmap_to_bytes_apply_crop(qapp, imgfilename3x3):
|
||||
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
|
||||
item.crop = QtCore.QRectF(0, 0, 2, 2)
|
||||
item.grayscale = True
|
||||
data, imgformat = item.pixmap_to_bytes(apply_crop=True)
|
||||
assert imgformat == 'png'
|
||||
assert data.startswith(b'\x89PNG')
|
||||
pixmap = QtGui.QPixmap()
|
||||
pixmap.loadFromData(data)
|
||||
img = pixmap.toImage()
|
||||
assert img.allGray() is False
|
||||
assert img.size() == QtCore.QSize(2, 2)
|
||||
|
||||
|
||||
def test_pixmap_to_bytes_apply_grayscale_crop_when_not_set(
|
||||
qapp, imgfilename3x3):
|
||||
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
|
||||
data, imgformat = item.pixmap_to_bytes(apply_grayscale=True,
|
||||
apply_crop=False)
|
||||
assert imgformat == 'png'
|
||||
assert data.startswith(b'\x89PNG')
|
||||
pixmap = QtGui.QPixmap()
|
||||
pixmap.loadFromData(data)
|
||||
img = pixmap.toImage()
|
||||
assert img.allGray() is False
|
||||
assert img.size() == QtCore.QSize(3, 3)
|
||||
|
||||
|
||||
def test_pixmap_from_bytes(qapp, item, imgfilename3x3):
|
||||
|
|
|
|||
|
|
@ -298,7 +298,7 @@ def test_on_action_save_when_no_filename(save_as_mock, view, imgfilename3x3):
|
|||
@patch('beeref.widgets.SceneToPixmapExporterDialog.value')
|
||||
@patch('PyQt6.QtWidgets.QFileDialog.getSaveFileName')
|
||||
def test_on_action_export_scene(
|
||||
file_mock, value_mock, exec_mock, view, tmpdir):
|
||||
file_mock, value_mock, exec_mock, view, tmpdir, qtbot):
|
||||
item = BeeTextItem('foo')
|
||||
view.scene.addItem(item)
|
||||
filename = os.path.join(tmpdir, 'test.png')
|
||||
|
|
@ -306,7 +306,11 @@ def test_on_action_export_scene(
|
|||
file_mock.return_value = (filename, None)
|
||||
exec_mock.return_value = 1
|
||||
value_mock.return_value = QtCore.QSize(100, 100)
|
||||
view.on_export_finished = MagicMock()
|
||||
|
||||
view.on_action_export_scene()
|
||||
qtbot.waitUntil(lambda: view.on_export_finished.called is True)
|
||||
view.on_export_finished.assert_called_once_with(filename, [])
|
||||
img = QtGui.QImage(filename)
|
||||
assert img.size() == QtCore.QSize(100, 100)
|
||||
|
||||
|
|
@ -315,7 +319,7 @@ def test_on_action_export_scene(
|
|||
@patch('beeref.widgets.SceneToPixmapExporterDialog.value')
|
||||
@patch('PyQt6.QtWidgets.QFileDialog.getSaveFileName')
|
||||
def test_on_action_export_scene_no_file_extension(
|
||||
file_mock, value_mock, exec_mock, view, tmpdir):
|
||||
file_mock, value_mock, exec_mock, view, tmpdir, qtbot):
|
||||
item = BeeTextItem('foo')
|
||||
view.scene.addItem(item)
|
||||
filename = os.path.join(tmpdir, 'test')
|
||||
|
|
@ -323,7 +327,11 @@ def test_on_action_export_scene_no_file_extension(
|
|||
file_mock.return_value = (filename, 'PNG (*.png)')
|
||||
exec_mock.return_value = 1
|
||||
value_mock.return_value = QtCore.QSize(100, 100)
|
||||
view.on_export_finished = MagicMock()
|
||||
|
||||
view.on_action_export_scene()
|
||||
qtbot.waitUntil(lambda: view.on_export_finished.called is True)
|
||||
view.on_export_finished.assert_called_once_with(f'{filename}.png', [])
|
||||
img = QtGui.QImage(f'{filename}.png')
|
||||
assert img.size() == QtCore.QSize(100, 100)
|
||||
|
||||
|
|
@ -336,9 +344,12 @@ def test_on_action_export_scene_no_filename(
|
|||
item = BeeTextItem('foo')
|
||||
view.scene.addItem(item)
|
||||
file_mock.return_value = (None, None)
|
||||
view.on_export_finished = MagicMock()
|
||||
|
||||
view.on_action_export_scene()
|
||||
exec_mock.assert_not_called()
|
||||
value_mock.assert_not_called()
|
||||
view.on_export_finished.assert_not_called()
|
||||
|
||||
|
||||
@patch('beeref.widgets.SceneToPixmapExporterDialog.exec')
|
||||
|
|
|
|||
Loading…
Reference in a new issue