Aluode/PerceptionLabPortable
0
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 