Source code for pylab_ml.misc.iasvg

# -*- coding: utf-8 -*-
"""
Created on Fri Mar  4 12:00:27 2022

Create interactive widgets from an svg image.

@author: jung

This lib is based on the following information or other libs:
    - https://pypi.org/project/svgelements/  for calculation from the transformation matrix
    - animated svg graphics:  https://funprojects.blog/2021/12/07/animated-svg-graphics-in-python/
        -> principle adopted for changing text
    - selectable image: https://stackoverflow.com/questions/59090988/clickable-svg-image-to-run-method
        - image is not adjusted to the size of the window
        - not applicable to items within an image, because transform from parent missing
    - https://forum.qt.io/topic/109025/updating-qgraphicseffect-for-qgraphicssvgitem/6

    - QtWidgets.QGraphicsView
        https://doc.qt.io/qt-5/qgraphicsview.html


What is available for reading, writing, manipulating from svg-files:
    - svgelements https://github.com/meerk40t/svgelements      -> this lib is used in this class
        - more robust as svg.path, include other elements like points, matrix, color
    - SVG manipulation within a GUI https://github.com/MoplusplusApp/Moplusplus
    - https://doc.qt.io/qtforpython/overviews/graphicsview.html
    - svglib
        - only reading SVG files and converting them (to a reasonable degree) to other formats using the ReportLab Open Source toolkit.
    - CairoSVG  https://cairosvg.org/
        - convert to png, pdf, ps and svg
    - svg.path
        - svg.path is a collection of objects that implement the different path commands in SVG, and a parser for SVG path definitions.
    - drawSvg
        - A Python 3 library for programmatically generating SVG images (vector drawings) and rendering them or displaying them in a Jupyter notebook.
    - svgutils  https://svgutils.readthedocs.io/en/latest/transform.html
        - basic svg transformations


TODO:   
    - si units missing
    - min,max values for the QDoubleSpinBox
    - show combobox for string values

    - mouse bewegungen abfangen und setToolTip anzeigen
"""

from lxml import etree
import svgelements  # https://pypi.org/project/svgelements/
from PyQt5 import QtWidgets, QtCore
from PyQt5.QtSvg import QGraphicsSvgItem, QSvgRenderer
from qtpy.QtCore import Signal


[docs] class SvgItem(QGraphicsSvgItem): """ SvgItem is a QGraphicsSvgItem with some additional functions for interactivity. """ clickBox = Signal(str, QtCore.QPoint) clickHelpBox = Signal(str, QtCore.QPoint)
[docs] def __init__(self, id, renderer, parent=None, element=None): """ Constructor for the SvgItem. Parameters ---------- id : string id from the svg element. renderer : QSvgRenderer The SVG renderer to use for this item. parent : TYPE, optional The parent item. The default is None. element : TYPE, optional The SVG element associated with this item. The default is None. """ super().__init__(parent) self.id = id self.setSharedRenderer(renderer) # necessary for setting lifetime bounds = renderer.boundsOnElement(id) self.setElementId(id) self.setPos(bounds.topLeft()) self.setFlags(QGraphicsSvgItem.ItemIsSelectable) # | QGraphicsSvgItem.ItemSendsGeometryChanges) # self.installEventFilter(self) self.boxes = {}
[docs] def mousePressEvent(self, event: "QtWidgets.QGraphicsSceneMouseEvent"): """ Overwrite the mousePressEvent from QGraphicsSvgItem. Send the clickBox or clickHelpBox Signal with the name from the element and the coordinates. Parameters ---------- event : 'QtWidgets.QGraphicsSceneMouseEvent' The mouse event that triggered the function. Returns ------- None. """ x = event.pos().x() y = event.pos().y() for tid in self.boxes: # find the accociated box and get the text attribute box = self.boxes[tid] if (x >= box["x"] and x <= box["x"] + box["w"]) and (y <= box["y"] and y >= box["y"] - box["h"]): rectangle = self.mapRectToScene(box["x"], box["y"], box["w"], box["h"]) # Scenen coordinats # rectangle = self.mapRectFromScene(box['x'], box['y'], box['w'], box['h']) # item coordinats( rectangle = QtCore.QRectF( event.screenPos().x(), event.screenPos().y(), rectangle.width(), rectangle.height() ) point = QtCore.QPoint(int(rectangle.x()), int(rectangle.y())) if event.button() == 1: # right Mousebutton self.clickBox.emit(tid, point) elif event.button() == 2: # left Mousebutton self.clickHelpBox.emit(tid, point) super().mousePressEvent(event)
[docs] def paint(self, painter, option, widget=None): """ Overwerite the paint-function from QGraphicsSvgItem. Parameters ---------- painter : QPainter The painter to use for drawing the item. option : QStyleOptionGraphicsItem The style options for the item. widget : QWidget, optional The widget that is being painted on. The default is None. Returns ------- None. """ # set a graphics colorize effect when item is selected, and disable the effect when deselected # if self.isSelected(): # self._effect = QtCore.QGraphicsColorizeEffect() # self._effect.setColor(Qt.red) # self._effect.setStrength(.5) # self.setGraphicsEffect(self._effect) # else: # pass # #self.setGraphicsEffect(0) QGraphicsSvgItem.paint(self, painter, option, widget)
[docs] def eventFilter(self, obj, event): """ Set an event Filter. Parameters ---------- obj : QObject The object that is being filtered. event : QEvent The event that is being filtered. Returns ------- bool True if the event should be filtered out, False otherwise. """ print(event.type()) return super(QGraphicsSvgItem, self).eventFilter(obj, event)
[docs] class SvgIaViewer(QtWidgets.QGraphicsView): """ SVG Interactive Viewer """
[docs] def __init__(self, parent): super().__init__(parent) self._scene = QtWidgets.QGraphicsScene(self) # https://doc.qt.io/qtforpython/overviews/graphicsview.html self._renderer = QSvgRenderer() self.setScene(self._scene)
[docs] def setSvg(self, filename): """ Render the svg-file 'filename'. Parameters ---------- filename : string The path to the SVG file to be rendered. Returns ------- None. """ self.resetTransform() self._scene.clear() self.root = etree.parse(filename) result = self._renderer.load(filename) if not result: print(f"couldn't load {filename}") return self.mainSvg = SvgItem(self.getid(self.root.getroot()), self._renderer) self.mainSvg.setX(0) self.mainSvg.setY(0) self.mainSvg.setElementId("") # force repainting, otherwise the image is empty... self._scene.addItem(self.mainSvg)
[docs] def updateSvg(self): """ Update the rendering from the svg. This is necessary after changing the svg-tree, otherwise the changes aren't visible. """ update = etree.tostring(self.root, xml_declaration=True) self._renderer.load(update) self.mainSvg.setElementId("") # force repainting
[docs] def setBox(self, name, element): """ Found the retangle from the element, save the coordinates in self.mainSvg.boxes and transformed to the parent position. Each element must be placed within a rectangle. Parameters ---------- searchid : string The id from the element, which is searched for. element : <Element> from etree The element, which is searched for. This is necessary, because not all elements have an id, so the element must be given directly. Returns ------- list x,y position and widht/height from the retangle. """ bmatrix = None box = None parentid = self.getid(element) root = element while element.getparent() is not None: # go up and search for the first rectangle if parentid is not None: item = SvgItem(parentid, self._renderer, element=element) if box is None and item.boundingRect().width() != 0.0 and "transform" in element.keys(): box = item.boundingRect() bmatrix = svgelements.Matrix(element.attrib["transform"]) box = {"w": box.width(), "h": box.height()} elif ( box is None and element.getchildren() != [] ): # there are no id's in orginal visio-> svg-files, so use another methode for child in element.getchildren(): if "d" in child.keys(): box = svgelements.Path(child.attrib["d"]) box = {"w": box.bbox()[2], "h": box.bbox()[3]} bmatrix = svgelements.Matrix(element.attrib["transform"]) element = element.getparent() parentid = self.getid(element) if "transform" in element.keys() and box is not None: bmatrix *= svgelements.Matrix(element.attrib["transform"]) # recalcuate the transform matrix if bmatrix is not None: self.mainSvg.boxes[name] = {"x": bmatrix.e, "y": bmatrix.f, "w": box["w"], "h": box["h"]} root.set("x", "1.0")
[docs] def getid(self, element): """ Get the id from the element. Not all elements have an id. It depends on the program with which the svg-image was created :-( If now id found than it returns None Parameters ---------- element : <Element> from etree The element, which is searched for the id. Returns ------- result : string The id from the element. """ try: result = element.attrib["id"] except Exception: result = None return result
[docs] def getAllTxtwColor(self, color, tlen=3): """ Give a dictionary from all Text elements with the specific color and a length from minimum tlen. Parameters ---------- color : string Color which is used. tlen : int Minimum text length Returns ------- result : dict A dictionary with the text as key and the element, the get/set attribute and the type as value. """ color = color.upper() result = {} for element in self.root.iter("*"): if ( "fill" in element.keys() and element.attrib["fill"].upper() == color and element.text is not None and len(element.text) >= tlen ): result[element.text] = element, "get", "text" return result
[docs] def getAllTxtColors(self, tlen=3): """ Give a list of colors from all text elements with a length from minimum tlen. Parameters ---------- tlen : int Minimum text length Returns ------- result : list A list with the colors. """ result = [] for element in self.root.iter("*"): if "fill" in element.keys() and element.text is not None and len(element.text) >= tlen: color = element.attrib["fill"].upper() if color not in result: result.append(element.attrib["fill"].upper()) return result
[docs] class InteractiveSvgWidget(QtWidgets.QWidget): """ Interactive SVG Widget. Make a svg image interactive. You can set values and replace elements inside the svg-image. """ editWidth = 60 editHeight = 20 changedId = Signal(str, str)
[docs] def __init__(self, svgfilename, firstname=""): """ Constructor for the InteractiveSvgWidget. Parameters ---------- svgfilename : string The path to the SVG file to be rendered. firstname : string, optional The name of the widget, which is used for the signal. The default is "". """ super().__init__() self.viewer = SvgIaViewer(self) self.firstname = firstname vb_layout = QtWidgets.QVBoxLayout(self) vb_layout.addWidget(self.viewer) self.elements = {} self.viewer.setSvg(svgfilename) self.viewer.mainSvg.clickBox.connect(self._clickBox) self.viewer.mainSvg.clickHelpBox.connect(self._clickHelpBox) self.setGeometry(0, 0, int(self.viewer.scene().width()) + 50, int(self.viewer.scene().height()) + 50) self.setMouseTracking = True self.setAutoFillBackground(True) # self.setStyleSheet("""QToolTip {color: black; border: black solid 1px}""") self.edit = QtWidgets.QDoubleSpinBox(self.viewer) self.edit.editingFinished.connect(lambda: self._editfinish()) self.edit.hide() self.edit.abort = False
[docs] def loadSvg(self, filename, svgtxt=None): """ Load the svg-file and parse it. Parameters ---------- filename : string The path to the SVG file to be loaded. svgtxt : string, optional The text to search for in the SVG elements. If None, the entire SVG is returned. Returns ------- svg : etree.ElementTree The parsed SVG file. element : etree.Element, optional The parent element of the found text. Returned if svgtxt is provided and found. """ svg = etree.parse(filename) if svgtxt is None: return svg available = [] for element in svg.iter("*"): if element.text == svgtxt: return element.getparent() elif element.text is not None and element.text[0] != "\n": available.append(element.text) print(f"loadSvg: {svgtxt} not found, but found {available}") return None
[docs] def replaceSvg(self, name, replace): """ Replace the svg-element with 'name'. Parameters ---------- name : string The name of the element to be replaced. replace : etree.Element The element to replace with. Returns ------- None. """ child = self._getGrandChildElement(self.elements[name][0].getparent(), "d") child.attrib["d"] = self._getGrandChildElement(replace, "d").attrib["d"] self.viewer.updateSvg()
def _getGrandChildElement(self, element, attrib): """ Serach for 'attrib' in all grandchild.items(). Parameters ---------- element : etree.Element The element to search for the grandchild with the attribute. attrib : string The name of the attribute to search for. Returns ------- result : etree.Element The grandchild element with the specified attribute, or None if not found. """ result = None for child in element.getchildren(): for grandchild in child.getchildren(): if attrib in grandchild.keys(): result = grandchild break return result
[docs] def setTextColor(self, color): """ Fill the dictionary 'self.elements' with all Text elements with this color, listen to getattribute. Parameters ---------- color : string The color to filter the text elements. Returns ------- None. """ self.elements.update(self.viewer.getAllTxtwColor(color))
[docs] def setAllsvgElementsWTxt(self, svgtxt): """ Fill the dictionary 'self.elements' with all elements, where text is starting with the string 'svgtxt'. Parameters ---------- svgtxt : string The text to search for in the SVG elements. Returns ------- result : list Return a list with the elements. """ for element in self.viewer.root.iter("*"): if element.text is not None and element.text.find(svgtxt) == 0: self.elements[element.text] = element, None, "element"
[docs] def setclickableText(self): """ Make 'self.elements' with the attribute 'text' clickable, now the element listen to setattr. """ for name in self.elements: if self.elements[name][2] == "text" and self.elements[name][1] == "get": self.viewer.setBox(name, self.elements[name][0]) self.elements[name] = self.elements[name][0], "set", "text"
[docs] def setElementSvg(self, name=None, *files): """ Set all 'self.elements' with the attribute 'element'==None to get(=listen to getattr). Load all files and search for the parent with the name. Parameters ---------- name : string, optional The text to search for in the SVG elements. If None, the first element with the attribute 'element'==None is used. The default is None. *files : string The paths to the SVG files to be loaded and used for replacement. Returns ------- None. """ mySvg = [] for file in files: mySvg.append(self.loadSvg(file, name)) for ename in self.elements: if self.elements[ename][2] == "element" and self.elements[ename][1] is None: self.replaceSvg(ename, mySvg[0]) self.elements[ename] = self.elements[ename][0], "get", "element", mySvg, 0
[docs] def setclickableElement(self): """ Make all 'self.elements' with the attribute 'element' clickable, now the element listen to setattr. """ box = None for name in self.elements: if self.elements[name][2] == "element" and self.elements[name][1] == "get": element = self.elements[name][0].getparent() box = None bmatrix = None # todo: bmatrix = Matrix(1, 0, 0, 1, 0, 0) while element is not None: if box is None: grandchild = self._getGrandChildElement(element, "d") if grandchild is not None: box = svgelements.Path(grandchild.attrib["d"]) box = { "w": box.bbox()[2], "h": box.bbox()[3], } # see https://github.com/meerk40t/svgelements element = grandchild.getparent() if box is not None and "transform" in element.keys(): if bmatrix is None: bmatrix = svgelements.Matrix(element.attrib["transform"]) else: bmatrix *= svgelements.Matrix(element.attrib["transform"]) element = element.getparent() if box is not None: self.viewer.mainSvg.boxes[name] = {"x": bmatrix.e, "y": bmatrix.f, "w": box["w"], "h": box["h"]} element = list(self.elements[name]) element[1] = "set" self.elements[name] = element return
[docs] def setIdText(self, tid, text): """ Set the element with the name 'tid' to the value 'text'. Parameters ---------- tid : string Text id. text: string New text string Returns ------- None. """ element = self.elements[tid][0] if self.elements[tid][2] == 'text': element.text = f"{tid}:{text}" self.viewer.updateSvg() elif self.elements[tid][2] == 'element': self.replaceSvg(tid, self.elements[tid][3][text])
[docs] def getAllTxtColors(self, tlen=3): """ Give a list of colors from all text elements with a length from minimum tlen. Parameters ---------- tlen : int Minimum text length Returns ------- result : list A list of colors from all text elements with a length from minimum tlen. """ result = [] for element in self.viewer.root.iter("*"): if "fill" in element.keys() and element.text is not None and len(element.text) >= tlen: color = element.attrib["fill"].upper() if color not in result: result.append(element.attrib["fill"].upper()) return result
@QtCore.pyqtSlot(str, QtCore.QPoint) def _clickBox(self, tid, viewpoint): """ Click on the box with the text 'tid' and show the edit field. Parameters ---------- tid : string Text id. viewpoint : QPoint The position of the mouse click in global coordinates. Returns ------- None. """ if tid is None: self._editfinish() typ = self.elements[tid][2] if typ == "text": self.edit.element = self.elements[tid][0] color = self.edit.element.attrib["fill"] text = self.edit.element.text.split(":") value = float(text[-1]) if len(text) > 1 and text[-1].find("None") < 0 else 0 viewpoint = self.mapFromGlobal(viewpoint) self.edit.setGeometry(QtCore.QRect(viewpoint.x(), viewpoint.y(), self.editWidth, self.editHeight)) self.edit.setStyleSheet(f"color: {color}") self.edit.setValue(value) self.edit.show() elif typ == "element": index = self.elements[tid][4] + 1 if self.elements[tid][4] + 1 < len(self.elements[tid][3]) else 0 self.replaceSvg(tid, self.elements[tid][3][index]) self.elements[tid] = ( self.elements[tid][0], self.elements[tid][1], self.elements[tid][2], self.elements[tid][3], index, ) self.changedId.emit(f"{self.firstname}.{tid}", str(index)) # send the signal to the parent @QtCore.pyqtSlot(str, QtCore.QPoint) def _clickHelpBox(self, tid, viewpoint): """ Click on the help box with the text 'tid' and show the tooltip. Parameters ---------- tid : string Text id. viewpoint : QPoint The position of the mouse click in global coordinates. Returns ------- None. """ # ToDo: create a QLabel and show it element = self.elements[tid][0] msg = f"{element.text}: This could be a Tip\n With some information about the using\n :-)" print(msg) self.setToolTip(msg) def _editfinish(self): """ Finish the editing of the text and set the new value. """ if self.edit.abort: self.edit.abort = False return self.edit.abort = False newvalue = self.edit.value() text = self.edit.element.text.split(":") self.edit.hide() self.edit.element.text = f"{text[0]}:{newvalue}" self.viewer.updateSvg() self.changedId.emit(f"{self.firstname}.{text[0]}", str(newvalue)) # send the signal to the parent
[docs] def resizeEvent(self, newSize): """Overwrite the resizeEvent from QWidget. This isn't running correctl, it must be improved. Parameters ---------- newSize : QResizeEvent The new size of the widget after resizing. Returns ------- None. """ self.edit.hide() if newSize.oldSize().width() < 0: self.startSize = newSize.size() self.viewer.mainSvg.setScale(1.0) else: scale = newSize.size().width() / self.startSize.width() self.viewer.mainSvg.setScale(scale) super().resizeEvent(newSize)
[docs] def keyPressEvent(self, event): """ Escape close the edit-field and refuse the value. Parameters ---------- event : QKeyEvent The key event that triggered this method. Returns ------- None. """ if event.key() == QtCore.Qt.Key_Escape: self.edit.abort = True self.edit.hide() super().keyPressEvent(event)
if __name__ == "__main__": import sys import os app = QtWidgets.QApplication(sys.argv) dirname = os.path.dirname(__file__) + "../../gui/instruments/miniSCT/" filename = "SCT8_v7_PDC.svg" window = InteractiveSvgWidget(dirname + filename) # this is the orginal svg data print(f"The used Text colors are : {window.getAllTxtColors()}") # set/get window.setTextColor( "#c00000" ) # fill a dictionary 'elements' with all Text elements with this color, listen to getattribute window.setclickableText() # make the 'elements' clickable, now the element also listen to setattr window.setAllsvgElementsWTxt( "SPST" ) # search for all elements which has an text item 'SPST' -> this are the switches window.setElementSvg("SWITCH", dirname + "sw0.svg", dirname + "sw1.svg") window.setclickableElement() # make the elements cklickable window.setAllsvgElementsWTxt("SPDT") # double-throw switch # window.setElementSvg('SWITCH', dirname+'sw0.svg', dirname+'sw1.svg') # window.setclickableElement() # only get window.setTextColor("#00B050") # all green Textboxes are only listening # set the get's window.setIdText("i_cnt_avrg", 100) window.setIdText("i_meas", 0.45) window.setIdText('SPST.252', 1) window.setStyleSheet("background-color: white;") # window.setGeometry(500, 300, 900, 700) window.show() sys.exit(app.exec_())