From cc47eb2070d9a1aeb208debe0a218a9405ee5e2a Mon Sep 17 00:00:00 2001 From: Rebecca Breu Date: Fri, 29 Dec 2023 16:59:42 +0100 Subject: [PATCH] Add export format SVG --- beeref/fileio/export.py | 162 +++++++++++++++++++++++++++++++++++++--- beeref/items.py | 12 ++- beeref/view.py | 49 ++++++------ 3 files changed, 185 insertions(+), 38 deletions(-) diff --git a/beeref/fileio/export.py b/beeref/fileio/export.py index 223483d..30fc342 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 . +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,19 +65,40 @@ 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(), @@ -66,9 +106,107 @@ def render_to_image(self, size): painter.end() return image - def export(self, filename, size): + def export(self, filename): logger.debug(f'Exporting scene to {filename}') - image = self.render_to_image(size) + image = self.render_to_image() if not image.save(filename, quality=90): raise BeeFileIOError( msg=str('Error writing image'), filename=filename) + logger.debug('Export finished') + + +@register_exporter +class SceneToSVGExporter(ExporterBase): + + TYPE = 'svg' + + def get_user_input(self, parent): + self.size = self.default_size + return True + + def render_to_svg(self): + 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 item in 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': + element = ET.Element('text') + element.text = item.toPlainText() + styles = ['white-space:pre'] + font = item.font() + fontsize = font.pointSize() * item.scale() + styles.append(f'font-size:{fontsize}pt') + families = ', '.join(font.families()) + styles.append(f'font-family:{families}') + element.set('style', ';'.join(styles)) + element.set('dominant-baseline', 'hanging') + if item.TYPE == 'pixmap': + 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 = item.crop.width() * item.scale() + height = item.crop.height() * item.scale() + element.set('width', str(width)) + element.set('height', str(height)) + print(item.scale()) + element.set( + 'image-rendering', + 'crisp-edges' if item.scale() > 2 else 'optimizeQuality') + pos = pos + item.crop.topLeft() + + element.set('opacity', str(item.opacity())) + + 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())) + + svg.append(element) + + return svg + + def export(self, filename): + logger.debug(f'Exporting scene to {filename}') + svg = self.render_to_svg() + + 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: + raise BeeFileIOError( + msg=str(f'Error writing image: {e}'), filename=filename) + + logger.debug('Export finished') diff --git a/beeref/items.py b/beeref/items.py index 39d4aa8..1750021 100644 --- a/beeref/items.py +++ b/beeref/items.py @@ -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) diff --git a/beeref/view.py b/beeref/view.py index fb92a0c..70361ed 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -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,32 @@ 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 + + try: + exporter.export(filename) + except fileio.BeeFileIOError as e: + QtWidgets.QMessageBox.warning( + self, + 'Problem exporting scene', + str(e)) def on_action_quit(self): logger.info('User quit. Exiting...')