CoolFace
Apppublic

Aluode/PerceptionLabPortable

sourceHugging Faceupdated 9mo agoView on Hugging Face
0likes
SVGExporter.py538 linesDownload Raw Back to exporters
1__all__ = ['SVGExporter']2 3import contextlib4import re5import xml.dom.minidom as xml6 7import numpy as np8 9from .. import debug10from .. import functions as fn11from ..parametertree import Parameter12from ..Qt import QtCore, QtGui, QtSvg, QtWidgets13from .Exporter import Exporter14 15translate = QtCore.QCoreApplication.translate16 17class SVGExporter(Exporter):18    Name = "Scalable Vector Graphics (SVG)"19    allowCopy=True20    21    def __init__(self, item):22        Exporter.__init__(self, item)23        tr = self.getTargetRect()24 25        scene = item.scene() if isinstance(item, QtWidgets.QGraphicsItem) else item26        bgbrush = scene.views()[0].backgroundBrush()27        bg = bgbrush.color()28        if bgbrush.style() == QtCore.Qt.BrushStyle.NoBrush:29            bg.setAlpha(0)30 31        self.params = Parameter.create(name='params', type='group', children=[32            {33                'name': 'background',34                'title': translate("Exporter", 'background'),35                'type': 'color',36                'value': bg37            },38            {39                'name': 'width',40                'title': translate("Exporter", 'width'),41                'type': 'float',42                'value': tr.width(),43                'limits': (0, None)44            },45            {46                'name': 'height',47                'title': translate("Exporter", 'height'),48                'type': 'float',49                'value': tr.height(),50                'limits': (0, None)},51            #{'name': 'viewbox clipping', 'type': 'bool', 'value': True},52            #{'name': 'normalize coordinates', 'type': 'bool', 'value': True},53            {54                'name': 'scaling stroke',55                'title': translate("Exporter", 'scaling stroke'),56                'type': 'bool',57                'value': False,58                'tip': "If False, strokes are non-scaling, which means that "59                       "they appear the same width on screen regardless of "60                       "how they are scaled or how the view is zoomed."61            },62        ])63        self.params.param('width').sigValueChanged.connect(self.widthChanged)64        self.params.param('height').sigValueChanged.connect(self.heightChanged)65 66    def widthChanged(self):67        sr = self.getSourceRect()68        ar = sr.height() / sr.width()69        self.params.param('height').setValue(self.params['width'] * ar, blockSignal=self.heightChanged)70        71    def heightChanged(self):72        sr = self.getSourceRect()73        ar = sr.width() / sr.height()74        self.params.param('width').setValue(self.params['height'] * ar, blockSignal=self.widthChanged)75        76    def parameters(self):77        return self.params78    79    def export(self, fileName=None, toBytes=False, copy=False):80        if toBytes is False and copy is False and fileName is None:81            self.fileSaveDialog(filter=f"{translate('Exporter', 'Scalable Vector Graphics')} (*.svg)")82            return83        84        ## Qt's SVG generator is not complete. (notably, it lacks clipping)85        ## Instead, we will use Qt to generate SVG for each item independently,86        ## then manually reconstruct the entire document.87        options = {ch.name():ch.value() for ch in self.params.children()}88        options['background'] = self.params['background']89        options['width'] = self.params['width']90        options['height'] = self.params['height']91        xml = generateSvg(self.item, options)92        if toBytes:93            return xml.encode('UTF-8')94        elif copy:95            md = QtCore.QMimeData()96            md.setData('image/svg+xml', QtCore.QByteArray(xml.encode('UTF-8')))97            QtWidgets.QApplication.clipboard().setMimeData(md)98        else:99            with open(fileName, 'wb') as fh:100                fh.write(xml.encode('utf-8'))101 102# Includes space for extra attributes103xmlHeader = """\104<?xml version="1.0" encoding="UTF-8" standalone="no"?>105<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"  version="1.2" baseProfile="tiny"%s>106<title>pyqtgraph SVG export</title>107<desc>Generated with Qt and pyqtgraph</desc>108<style>109    image {110        image-rendering: crisp-edges;111        image-rendering: -moz-crisp-edges;112        image-rendering: pixelated;113    }114</style>115"""116 117def generateSvg(item, options=None):118    if options is None:119        options = {}120    global xmlHeader121    try:122        node, defs = _generateItemSvg(item, options=options)123    finally:124        ## reset export mode for all items in the tree125        if isinstance(item, QtWidgets.QGraphicsScene):126            items = item.items()127        else:128            items = [item]129            for i in items:130                items.extend(i.childItems())131        for i in items:132            if hasattr(i, 'setExportMode'):133                i.setExportMode(False)134    cleanXml(node)135    136    defsXml = "<defs>\n"137    for d in defs:138        defsXml += d.toprettyxml(indent='    ')139    defsXml += "</defs>\n"140    svgAttributes = f' viewBox ="0 0 {int(options["width"])} {int(options["height"])}"'141    c = options['background']142    backgroundtag = f'<rect width="100%" height="100%" fill="{c.name()}" fill-opacity="{c.alphaF()}" />\n'143    return (xmlHeader % svgAttributes) + backgroundtag + defsXml + node.toprettyxml(indent='    ') + "\n</svg>\n"144 145def _generateItemSvg(item, nodes=None, root=None, options=None):146    """This function is intended to work around some issues with Qt's SVG generator147    and SVG in general.148 149    .. warning::150        This function, while documented, is not considered part of the public151        API. The reason for its documentation is for ease of referencing by152        :func:`~pyqtgraph.GraphicsItem.generateSvg`. There should be no need153        to call this function explicitly.154 155    1. Qt SVG does not implement clipping paths. This is absurd.156    The solution is to let Qt generate SVG for each item independently,157    then glue them together manually with clipping.  The format Qt generates 158    for all items looks like this:159        160    .. code-block:: xml161    162        <g>163            <g transform="matrix(...)">164                one or more of: <path/> or <polyline/> or <text/>165            </g>166            <g transform="matrix(...)">167                one or more of: <path/> or <polyline/> or <text/>168            </g>169            . . .170        </g>171        172    2. There seems to be wide disagreement over whether path strokes173    should be scaled anisotropically.  Given that both inkscape and 174    illustrator seem to prefer isotropic scaling, we will optimize for175    those cases.176 177    .. note::178        179        see: http://web.mit.edu/jonas/www/anisotropy/180 181    3. Qt generates paths using non-scaling-stroke from SVG 1.2, but182    inkscape only supports 1.1.183 184    Both 2 and 3 can be addressed by drawing all items in world coordinates.185 186    Parameters187    ----------188    item : :class:`~pyqtgraph.GraphicsItem`189        GraphicsItem to generate SVG of190    nodes : dict of str, optional191        dictionary keyed on graphics item names, values contains the 192        XML elements, by default None193    root : :class:`~pyqtgraph.GraphicsItem`, optional194        root GraphicsItem, if none, assigns to `item`, by default None195    options : dict of str, optional196        Options to be applied to the generated XML, by default None197 198    Returns199    -------200    tuple201        tuple where first element is XML element, second element is 202        a list of child GraphicItems XML elements203    """204 205    profiler = debug.Profiler()206    if options is None:207        options = {}208 209    if nodes is None:  ## nodes maps all node IDs to their XML element.210                       ## this allows us to ensure all elements receive unique names.211        nodes = {}212 213    if root is None:214        root = item215 216    ## Skip hidden items217    if hasattr(item, 'isVisible') and not item.isVisible():218        return None219 220    with contextlib.suppress(NotImplementedError, AttributeError):221        # If this item defines its own SVG generator, use that.222        return item.generateSvg(nodes)223    ## Generate SVG text for just this item (exclude its children; we'll handle them later)224    if isinstance(item, QtWidgets.QGraphicsScene):225        xmlStr = "<g>\n</g>\n"226        doc = xml.parseString(xmlStr)227        childs = [i for i in item.items() if i.parentItem() is None]228    elif item.__class__.paint == QtWidgets.QGraphicsItem.paint:229        xmlStr = "<g>\n</g>\n"230        doc = xml.parseString(xmlStr)231        childs = item.childItems()232    else:233        childs = item.childItems()234 235        tr = itemTransform(item, item.scene())236        # offset to corner of root item237        if isinstance(root, QtWidgets.QGraphicsScene):238            rootPos = QtCore.QPoint(0,0)239        else:240            rootPos = root.scenePos()241 242        # handle rescaling from the export dialog243        if hasattr(root, 'boundingRect'):244            resize_x = options["width"] / root.boundingRect().width()245            resize_y = options["height"] / root.boundingRect().height()246        else:247            resize_x = resize_y = 1248        tr2 = QtGui.QTransform(resize_x, 0, 0, resize_y, -rootPos.x(), -rootPos.y())249        tr = tr * tr2250        # tr = manipulate * tr * tr2251 252        arr = QtCore.QByteArray()253        buf = QtCore.QBuffer(arr)254        svg = QtSvg.QSvgGenerator()255        svg.setOutputDevice(buf)256        dpi = QtGui.QGuiApplication.primaryScreen().logicalDotsPerInchX()257        svg.setResolution(int(dpi))258        p = QtGui.QPainter()259        p.begin(svg)260        if hasattr(item, 'setExportMode'):261            item.setExportMode(True, {'painter': p})262        try:263            p.setTransform(tr)264            opt = QtWidgets.QStyleOptionGraphicsItem()265            if item.flags() & QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemUsesExtendedStyleOption:266                opt.exposedRect = item.boundingRect()267            item.paint(p, opt, None)268        finally:269            p.end()270            ## Can't do this here--we need to wait until all children have painted as well.271            ## this is taken care of in generateSvg instead.272            # if hasattr(item, 'setExportMode'):273            #     item.setExportMode(False)274        doc = xml.parseString(arr.data())275 276    try:277        ## Get top-level group for this item278        g1 = doc.getElementsByTagName('g')[0]279        defs = doc.getElementsByTagName('defs')280        if len(defs) > 0:281            defs = [n for n in defs[0].childNodes if isinstance(n, xml.Element)]282    except:283        print(doc.toxml())284        raise285    profiler('render')286    ## Get rid of group transformation matrices by applying287    ## transformation to inner coordinates288    correctCoordinates(g1, defs, item, options)289    profiler('correct')290 291    ## decide on a name for this item292    baseName = item.__class__.__name__293    i = 1294    while True:295        name = baseName + "_%d" % i296        if name not in nodes:297            break298        i += 1299    nodes[name] = g1300    g1.setAttribute('id', name)301 302    ## If this item clips its children, we need to take care of that.303    childGroup = g1  ## add children directly to this node unless we are clipping304    if (305        not isinstance(item, QtWidgets.QGraphicsScene) and 306        item.flags() & item.GraphicsItemFlag.ItemClipsChildrenToShape307    ):308        ## Generate svg for just the path309        path = QtWidgets.QGraphicsPathItem(item.mapToScene(item.shape()))310        item.scene().addItem(path)311        try:312            pathNode = _generateItemSvg(path, root=root, options=options)[0].getElementsByTagName('path')[0]313            # assume <defs> for this path is empty.. possibly problematic.314        finally:315            item.scene().removeItem(path)316 317            ## and for the clipPath element318        clip = f'{name}_clip'319        clipNode = g1.ownerDocument.createElement('clipPath')320        clipNode.setAttribute('id', clip)321        clipNode.appendChild(pathNode)322        g1.appendChild(clipNode)323 324        childGroup = g1.ownerDocument.createElement('g')325        childGroup.setAttribute('clip-path', f'url(#{clip})')326        g1.appendChild(childGroup)327    profiler('clipping')328 329    ## Add all child items as sub-elements.330    childs.sort(key=lambda c: c.zValue())331    for ch in childs:332        csvg = _generateItemSvg(ch, nodes, root, options=options)333        if csvg is None:334            continue335        cg, cdefs = csvg336        childGroup.appendChild(cg)  ### this isn't quite right--some items draw below their parent (good enough for now)337        defs.extend(cdefs)338 339    profiler('children')340    return g1, defs341 342 343def correctCoordinates(node, defs, item, options):   344    # correct the defs in the linearGradient345    for d in defs:346        if d.tagName == "linearGradient":347            # reset "gradientUnits" attribute to SVG default value348            d.removeAttribute("gradientUnits")349 350            # replace with percentages351            for coord in ("x1", "x2", "y1", "y2"):352                if coord.startswith("x"):353                    denominator = item.boundingRect().width()354                else:355                    denominator = item.boundingRect().height()356                percentage = round(float(d.getAttribute(coord)) * 100 / denominator)357                d.setAttribute(coord, f"{percentage}%")358 359            # replace stops with percentages360            for child in filter(361                lambda e: isinstance(e, xml.Element) and e.tagName == "stop",362                d.childNodes363            ):364                offset = child.getAttribute("offset")365                try:366                    child.setAttribute("offset", f"{round(float(offset) * 100)}%")367                except ValueError:368                    # offset attribute could not be converted to float369                    # must be one of the other SVG accepted formats370                    continue371 372    ## Remove transformation matrices from <g> tags by applying matrix to coordinates inside.373    ## Each item is represented by a single top-level group with one or more groups inside.374    ## Each inner group contains one or more drawing primitives, possibly of different types.375    groups = node.getElementsByTagName('g')376 377    ## Since we leave text unchanged, groups which combine text and non-text primitives must be split apart.378    ## (if at some point we start correcting text transforms as well, then it should be safe to remove this)379    groups2 = []380    for grp in groups:381        subGroups = [grp.cloneNode(deep=False)]382        textGroup = None383        for ch in grp.childNodes[:]:384            if isinstance(ch, xml.Element):385                if textGroup is None:386                    textGroup = ch.tagName == 'text'387                if ch.tagName == 'text':388                    if textGroup is False:389                        subGroups.append(grp.cloneNode(deep=False))390                        textGroup = True391                else:392                    if textGroup is True:393                        subGroups.append(grp.cloneNode(deep=False))394                        textGroup = False395            subGroups[-1].appendChild(ch)396        groups2.extend(subGroups)397        for sg in subGroups:398            node.insertBefore(sg, grp)399        node.removeChild(grp)400    groups = groups2401 402    for grp in groups:403        matrix = grp.getAttribute('transform')404        match = re.match(r'matrix\((.*)\)', matrix)405        if match is None:406            vals = [1,0,0,1,0,0]407        else:408            vals = [float(a) for a in match.groups()[0].split(',')]409        tr = np.array([[vals[0], vals[2], vals[4]], [vals[1], vals[3], vals[5]]])410 411        removeTransform = False412        for ch in grp.childNodes:413            if not isinstance(ch, xml.Element):414                continue415            if ch.tagName == 'polyline':416                removeTransform = True417                coords = np.array([[float(a) for a in c.split(',')] for c in ch.getAttribute('points').strip().split(' ')])418                coords = fn.transformCoordinates(tr, coords, transpose=True)419                ch.setAttribute('points', ' '.join([','.join([str(a) for a in c]) for c in coords]))420            elif ch.tagName == 'path':421                removeTransform = True422                newCoords = ''423                oldCoords = ch.getAttribute('d').strip()424                if oldCoords == '':425                    continue426                for c in oldCoords.split(' '):427                    x,y = c.split(',')428                    if x[0].isalpha():429                        t = x[0]430                        x = x[1:]431                    else:432                        t = ''433                    nc = fn.transformCoordinates(tr, np.array([[float(x),float(y)]]), transpose=True)434                    newCoords += t+str(nc[0,0])+','+str(nc[0,1])+' '435                # If coords start with L instead of M, then the entire path will not be rendered.436                # (This can happen if the first point had nan values in it--Qt will skip it on export)437                if newCoords[0] != 'M':438                    newCoords = f'M{newCoords[1:]}'439                ch.setAttribute('d', newCoords)440            elif ch.tagName == 'text':441                removeTransform = False442                ## leave text alone for now. Might need this later to correctly render text with outline.443                # c = np.array([444                #     [float(ch.getAttribute('x')), float(ch.getAttribute('y'))], 445                #     [float(ch.getAttribute('font-size')), 0], 446                #     [0,0]])447                # c = fn.transformCoordinates(tr, c, transpose=True)448                # ch.setAttribute('x', str(c[0,0]))449                # ch.setAttribute('y', str(c[0,1]))450                # fs = c[1]-c[2]451                # fs = (fs**2).sum()**0.5452                # ch.setAttribute('font-size', str(fs))453 454                ## Correct some font information455                families = ch.getAttribute('font-family').split(',')456                if len(families) == 1:457                    font = QtGui.QFont(families[0].strip('" '))458                    if font.styleHint() == font.StyleHint.SansSerif:459                        families.append('sans-serif')460                    elif font.styleHint() == font.StyleHint.Serif:461                        families.append('serif')462                    elif font.styleHint() == font.StyleHint.Courier:463                        families.append('monospace')464                    ch.setAttribute('font-family', ', '.join([f if ' ' not in f else '"%s"'%f for f in families]))465 466            ## correct line widths if needed467            if removeTransform and ch.getAttribute('vector-effect') != 'non-scaling-stroke' and grp.getAttribute('stroke-width') != '':468                w = float(grp.getAttribute('stroke-width'))469                s = fn.transformCoordinates(tr, np.array([[w,0], [0,0]]), transpose=True)470                w = ((s[0]-s[1])**2).sum()**0.5471                ch.setAttribute('stroke-width', str(w))472 473            # Remove non-scaling-stroke if requested474            if options.get('scaling stroke') is True and ch.getAttribute('vector-effect') == 'non-scaling-stroke':475                ch.removeAttribute('vector-effect')476 477        if removeTransform:478            grp.removeAttribute('transform')479 480 481SVGExporter.register()        482 483 484def itemTransform(item, root):485    ## Return the transformation mapping item to root486    ## (actually to parent coordinate system of root)487 488    if item is root:489        tr = QtGui.QTransform()490        tr.translate(*item.pos())491        tr = tr * item.transform()492        return tr493 494    if item.flags() & item.GraphicsItemFlag.ItemIgnoresTransformations:495        pos = item.pos()496        parent = item.parentItem()497        if parent is not None:498            pos = itemTransform(parent, root).map(pos)499        tr = QtGui.QTransform()500        tr.translate(pos.x(), pos.y())501        tr = item.transform() * tr502    else:503        ## find next parent that is either the root item or504        ## an item that ignores its transformation505        nextRoot = item506        while True:507            nextRoot = nextRoot.parentItem()508            if nextRoot is None:509                nextRoot = root510                break511            if nextRoot is root or (nextRoot.flags() & nextRoot.GraphicsItemFlag.ItemIgnoresTransformations):512                break513        514        if isinstance(nextRoot, QtWidgets.QGraphicsScene):515            tr = item.sceneTransform()516        else:517            tr = itemTransform(nextRoot, root) * item.itemTransform(nextRoot)[0]518    519    return tr520 521            522def cleanXml(node):523    ## remove extraneous text; let the xml library do the formatting.524    hasElement = False525    nonElement = []526    for ch in node.childNodes:527        if isinstance(ch, xml.Element):528            hasElement = True529            cleanXml(ch)530        else:531            nonElement.append(ch)532    533    if hasElement:534        for ch in nonElement:535            node.removeChild(ch)536    elif node.tagName == 'g':  ## remove childless groups537        node.parentNode.removeChild(node)538 
Aluode/PerceptionLabPortable · CoolFace