diff --git a/pyqtgraph/__init__.py b/pyqtgraph/__init__.py index 09dc7c58..6e950770 100644 --- a/pyqtgraph/__init__.py +++ b/pyqtgraph/__init__.py @@ -50,7 +50,7 @@ CONFIG_OPTIONS = { def setConfigOption(opt, value): CONFIG_OPTIONS[opt] = value -def setConfigOptions(opts): +def setConfigOptions(**opts): CONFIG_OPTIONS.update(opts) def getConfigOption(opt): diff --git a/pyqtgraph/exporters/SVGExporter.py b/pyqtgraph/exporters/SVGExporter.py index bac03dae..4052deec 100644 --- a/pyqtgraph/exporters/SVGExporter.py +++ b/pyqtgraph/exporters/SVGExporter.py @@ -1,6 +1,7 @@ from .Exporter import Exporter from pyqtgraph.parametertree import Parameter from pyqtgraph.Qt import QtGui, QtCore, QtSvg +import pyqtgraph as pg import re import xml.dom.minidom as xml @@ -72,107 +73,217 @@ class SVGExporter(Exporter): ##print "old line:", line ##print "new line:", data[i] #open(fileName, 'w').write(''.join(data)) - - node = self.generateItemSvg(self.item) - xml = """\ - - -pyqtgraph SVG export -Generated with Qt and pyqtgraph - - -""" + node.toprettyxml(indent=' ') + "\n\n" - + + ## Qt's SVG generator is not complete. (notably, it lacks clipping) + ## Instead, we will use Qt to generate SVG for each item independently, + ## then manually reconstruct the entire document. + xml = generateSvg(self.item) + if toBytes: return bytes(xml) else: with open(fileName, 'w') as fh: fh.write(xml) - def generateItemSvg(self, item): +xmlHeader = """\ + + +pyqtgraph SVG export +Generated with Qt and pyqtgraph + + +""" + +def generateSvg(item): + global xmlHeader + try: + node = _generateItemSvg(item) + finally: + ## reset export mode for all items in the tree if isinstance(item, QtGui.QGraphicsScene): - xmlStr = "" - childs = [i for i in item.items() if i.parentItem() is None] + items = item.items() else: - tr = QtGui.QTransform() - tr.translate(item.pos().x(), item.pos().y()) - tr = tr * item.transform() - if not item.isVisible() or int(item.flags() & item.ItemHasNoContents) > 0: - m = (tr.m11(), tr.m12(), tr.m21(), tr.m22(), tr.m31(), tr.m32()) - #print item, m - xmlStr = '' % m - else: - arr = QtCore.QByteArray() - buf = QtCore.QBuffer(arr) - svg = QtSvg.QSvgGenerator() - svg.setOutputDevice(buf) - dpi = QtGui.QDesktopWidget().physicalDpiX() - ### not really sure why this works, but it seems to be important: - #self.svg.setSize(QtCore.QSize(self.params['width']*dpi/90., self.params['height']*dpi/90.)) - svg.setResolution(dpi) + items = [item] + for i in items: + items.extend(i.childItems()) + for i in items: + if hasattr(i, 'setExportMode'): + i.setExportMode(False) + + cleanXml(node) + + return xmlHeader + node.toprettyxml(indent=' ') + "\n\n" - p = QtGui.QPainter() - p.begin(svg) - if hasattr(item, 'setExportMode'): - item.setExportMode(True, {'painter': p}) - try: - #tr = QtGui.QTransform() - #tr.translate(item.pos().x(), item.pos().y()) - #p.setTransform(tr * item.transform()) - p.setTransform(tr) - item.paint(p, QtGui.QStyleOptionGraphicsItem(), None) - finally: - p.end() - if hasattr(item, 'setExportMode'): - item.setExportMode(False) - - xmlStr = str(arr) - childs = item.childItems() +def _generateItemSvg(item, nodes=None, root=None): + ## This function is intended to work around some issues with Qt's SVG generator + ## and SVG in general. + ## 1) Qt SVG does not implement clipping paths. This is absurd. + ## The solution is to let Qt generate SVG for each item independently, + ## then glue them together manually with clipping. + ## 2) There seems to be wide disagreement over whether path strokes + ## should be scaled anisotropically. + ## see: http://web.mit.edu/jonas/www/anisotropy/ + ## Given that both inkscape and illustrator seem to prefer isotropic + ## scaling, we will optimize for those cases. + ## 3) Qt generates paths using non-scaling-stroke from SVG 1.2, but + ## inkscape only supports 1.1. + + + + if nodes is None: ## nodes maps all node IDs to their XML element. + ## this allows us to ensure all elements receive unique names. + nodes = {} + + if root is None: + root = item + + ## Skip hidden items + if hasattr(item, 'isVisible') and not item.isVisible(): + return None + + ## If this item defines its own SVG generator, use that. + if hasattr(item, 'generateSvg'): + return item.generateSvg(nodes) + + + ## Generate SVG text for just this item (exclude its children; we'll handle them later) + tr = QtGui.QTransform() + if isinstance(item, QtGui.QGraphicsScene): + xmlStr = "\n\n" + childs = [i for i in item.items() if i.parentItem() is None] doc = xml.parseString(xmlStr) + else: + childs = item.childItems() + tr.translate(item.pos().x(), item.pos().y()) + tr = tr * item.transform() + #if not item.isVisible() or int(item.flags() & item.ItemHasNoContents) > 0: + #m = (tr.m11(), tr.m12(), tr.m21(), tr.m22(), tr.m31(), tr.m32()) + ##print item, m + #xmlStr = '' % m # note: outer group is needed to separate clipping from transform + #doc = xml.parseString(xmlStr) + #else: + arr = QtCore.QByteArray() + buf = QtCore.QBuffer(arr) + svg = QtSvg.QSvgGenerator() + svg.setOutputDevice(buf) + dpi = QtGui.QDesktopWidget().physicalDpiX() + ### not really sure why this works, but it seems to be important: + #self.svg.setSize(QtCore.QSize(self.params['width']*dpi/90., self.params['height']*dpi/90.)) + svg.setResolution(dpi) + + p = QtGui.QPainter() + p.begin(svg) + if hasattr(item, 'setExportMode'): + item.setExportMode(True, {'painter': p}) try: - groups = doc.getElementsByTagName('g') - if len(groups) == 1: - g1 = g2 = groups[0] - else: - g1,g2 = groups[:2] - except: - print doc.toxml() - raise - g1.setAttribute('id', item.__class__.__name__) - - ## Check for item visibility - visible = True - if not isinstance(item, QtGui.QGraphicsScene): - parent = item - while visible and parent is not None: - visible = parent.isVisible() - parent = parent.parentItem() + #p.setTransform(tr) + item.paint(p, QtGui.QStyleOptionGraphicsItem(), None) + finally: + p.end() + ## Can't do this here--we need to wait until all children have painted as well. + ## this is taken care of in generateSvg instead. + #if hasattr(item, 'setExportMode'): + #item.setExportMode(False) - if not visible: - style = g1.getAttribute('style').strip() - if len(style)>0 and not style.endswith(';'): - style += ';' - style += 'display:none;' - g1.setAttribute('style', style) - - childs.sort(key=lambda c: c.zValue()) - for ch in childs: - cg = self.generateItemSvg(ch) - g2.appendChild(cg) + xmlStr = str(arr) + doc = xml.parseString(xmlStr) - return g1 - - - - ### To check: - ### do all items really generate this double-group structure? - ### are both groups necessary? - ### How do we implement clipping? (can we clip to an object that is visible?) - - - + try: + ## Get top-level group for this item + g1 = doc.getElementsByTagName('g')[0] + ## get list of sub-groups + g2 = [n for n in g1.childNodes if isinstance(n, xml.Element) and n.tagName == 'g'] + except: + print doc.toxml() + raise + + ## make sure g1 has the transformation matrix + m = (tr.m11(), tr.m12(), tr.m21(), tr.m22(), tr.m31(), tr.m32()) + g1.setAttribute('transform', "matrix(%f,%f,%f,%f,%f,%f)" % m) + + #print "=================",item,"=====================" + #print g1.toprettyxml(indent=" ", newl='') + + ## Inkscape does not support non-scaling-stroke (this is SVG 1.2, inkscape supports 1.1) + ## So we need to correct anything attempting to use this. + correctStroke(g1, item, root) + + ## decide on a name for this item + baseName = item.__class__.__name__ + i = 1 + while True: + name = baseName + "_%d" % i + if name not in nodes: + break + i += 1 + nodes[name] = g1 + g1.setAttribute('id', name) + + ## If this item clips its children, we need to take car of that. + childGroup = g1 ## add children directly to this node unless we are clipping + if not isinstance(item, QtGui.QGraphicsScene): + ## See if this item clips its children + if int(item.flags() & item.ItemClipsChildrenToShape) > 0: + ## Generate svg for just the path + path = QtGui.QGraphicsPathItem(item.shape()) + pathNode = _generateItemSvg(path, root=root).getElementsByTagName('path')[0] + ## and for the clipPath element + clip = name + '_clip' + clipNode = g1.ownerDocument.createElement('clipPath') + clipNode.setAttribute('id', clip) + clipNode.appendChild(pathNode) + g1.appendChild(clipNode) + + childGroup = g1.ownerDocument.createElement('g') + childGroup.setAttribute('clip-path', 'url(#%s)' % clip) + g1.appendChild(childGroup) + ## Add all child items as sub-elements. + childs.sort(key=lambda c: c.zValue()) + for ch in childs: + cg = _generateItemSvg(ch, nodes, root) + if cg is None: + continue + childGroup.appendChild(cg) ### this isn't quite right--some items draw below their parent (good enough for now) + + return g1 +def correctStroke(node, item, root, width=1): + #print "==============", item, node + if node.hasAttribute('stroke-width'): + width = float(node.getAttribute('stroke-width')) + if node.getAttribute('vector-effect') == 'non-scaling-stroke': + node.removeAttribute('vector-effect') + if isinstance(root, QtGui.QGraphicsScene): + w = item.mapFromScene(pg.Point(width,0)) + o = item.mapFromScene(pg.Point(0,0)) + else: + w = item.mapFromItem(root, pg.Point(width,0)) + o = item.mapFromItem(root, pg.Point(0,0)) + w = w-o + #print " ", w, o, w-o + w = (w.x()**2 + w.y()**2) ** 0.5 + #print " ", w + node.setAttribute('stroke-width', str(w)) + + for ch in node.childNodes: + if isinstance(ch, xml.Element): + correctStroke(ch, item, root, width) + +def cleanXml(node): + ## remove extraneous text; let the xml library do the formatting. + hasElement = False + nonElement = [] + for ch in node.childNodes: + if isinstance(ch, xml.Element): + hasElement = True + cleanXml(ch) + else: + nonElement.append(ch) + + if hasElement: + for ch in nonElement: + node.removeChild(ch) diff --git a/pyqtgraph/widgets/RemoteGraphicsView.py b/pyqtgraph/widgets/RemoteGraphicsView.py index 5a389616..3752a6bb 100644 --- a/pyqtgraph/widgets/RemoteGraphicsView.py +++ b/pyqtgraph/widgets/RemoteGraphicsView.py @@ -21,7 +21,7 @@ class RemoteGraphicsView(QtGui.QWidget): QtGui.QWidget.__init__(self) self._proc = mp.QtProcess() self.pg = self._proc._import('pyqtgraph') - self.pg.setConfigOptions(self.pg.CONFIG_OPTIONS) + self.pg.setConfigOptions(**self.pg.CONFIG_OPTIONS) rpgRemote = self._proc._import('pyqtgraph.widgets.RemoteGraphicsView') self._view = rpgRemote.Renderer(*args, **kwds) self._view._setProxyOptions(deferGetattr=True) diff --git a/tests/svg.py b/tests/svg.py index dc9657bb..db54eb83 100644 --- a/tests/svg.py +++ b/tests/svg.py @@ -7,20 +7,39 @@ app = pg.mkQApp() class SVGTest(test.TestCase): def test_plotscene(self): - p = pg.plot([1,5,2,3,4,6,1,2,4,2,3,5,3]) - p.setXRange(0,5) - ex = pg.exporters.SVGExporter.SVGExporter(p.scene()) + pg.setConfigOption('foreground', (0,0,0)) + w = pg.GraphicsWindow() + w.show() + p1 = w.addPlot() + p2 = w.addPlot() + p1.plot([1,3,2,3,1,6,9,8,4,2,3,5,3], pen={'color':'k'}) + p1.setXRange(0,5) + p2.plot([1,5,2,3,4,6,1,2,4,2,3,5,3], pen={'color':'k', 'cosmetic':False, 'width': 0.3}) + app.processEvents() + app.processEvents() + + ex = pg.exporters.SVGExporter.SVGExporter(w.scene()) ex.export(fileName='test.svg') #def test_simple(self): #rect = pg.QtGui.QGraphicsRectItem(0, 0, 100, 100) - ##rect.rotate(30) + #rect.translate(50, 50) + #rect.rotate(30) #grp = pg.ItemGroup() #grp.setParentItem(rect) #grp.translate(200,0) - #grp.rotate(30) - #el = pg.QtGui.QGraphicsEllipseItem(10, 0, 100, 50) - #el.setParentItem(grp) + ##grp.rotate(30) + + #rect2 = pg.QtGui.QGraphicsRectItem(0, 0, 100, 25) + #rect2.setFlag(rect2.ItemClipsChildrenToShape) + #rect2.setParentItem(grp) + #rect2.setPos(0,25) + #rect2.rotate(30) + #el = pg.QtGui.QGraphicsEllipseItem(0, 0, 100, 50) + #el.translate(10,-5) + #el.scale(0.5,2) + #el.setParentItem(rect2) + #ex = pg.exporters.SVGExporter.SVGExporter(rect) #ex.export(fileName='test.svg')