Skip to content

Commit

Permalink
Add export format SVG
Browse files Browse the repository at this point in the history
rbreu committed Dec 31, 2023
1 parent b0775ca commit 1eab471
Showing 7 changed files with 686 additions and 51 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -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
214 changes: 200 additions & 14 deletions beeref/fileio/export.py
Original file line number Diff line number Diff line change
@@ -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):

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


MARGIN = 100
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,29 +65,196 @@ def __init__(self, scene):
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(),
target=target_rect)
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, [])
12 changes: 10 additions & 2 deletions beeref/items.py
Original file line number Diff line number Diff line change
@@ -169,12 +169,20 @@ def get_imgformat(self, img):
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)
57 changes: 33 additions & 24 deletions beeref/view.py
Original file line number Diff line number Diff line change
@@ -26,7 +26,7 @@
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 @@ def on_action_export_scene(self):
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...')
Loading

0 comments on commit 1eab471

Please sign in to comment.